diff --git a/.gitignore b/.gitignore index 9d600ed..10cd30d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,8 @@ screenjob.db # IDE .vscode/ .idea/ + +# Service host build/publish artifacts +service_host/**/bin/ +service_host/**/obj/ +service_host/publish/ diff --git a/README.md b/README.md index cad5954..d6e865f 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,77 @@ Or use the PowerShell launcher: .\start_backend.ps1 ``` +### Windows Service + +Run these from an elevated PowerShell session (Run as Administrator): +Requires .NET SDK 10+ (installer publishes a native service host executable). + +Install and start at boot: + +```powershell +.\install_backend_service.ps1 -ForceReinstall -StartAfterInstall -DelayedAutoStart +``` + +Check status: + +```powershell +Get-Service -Name ScreenJobBackend +``` + +Stop/start manually: + +```powershell +Stop-Service -Name ScreenJobBackend +Start-Service -Name ScreenJobBackend +``` + +Uninstall: + +```powershell +.\uninstall_backend_service.ps1 +``` + +Service logs are written to: + +```text +screenjob_runs/service/backend-service.stdout.log +screenjob_runs/service/backend-service.stderr.log +``` + +### System Tray Icon (Windows) + +Start tray icon now: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -STA -File .\screenjob_tray.ps1 +``` + +Install startup shortcut (current user): + +```powershell +.\install_tray_startup_shortcut.ps1 +``` + +Install startup shortcut for all users: + +```powershell +.\install_tray_startup_shortcut.ps1 -AllUsers +``` + +Remove startup shortcut: + +```powershell +.\install_tray_startup_shortcut.ps1 -Remove +``` + +Tray menu actions: + +- Refresh service status +- Start/Stop/Restart service (prompts for admin/UAC) +- Open dashboard URL from `.env` `SCREENJOB_HOST` / `SCREENJOB_PORT` +- Open service logs folder +- Exit tray icon process + Auth for all API routes: - `Authorization: Bearer ` diff --git a/install_backend_service.ps1 b/install_backend_service.ps1 new file mode 100644 index 0000000..29ed061 --- /dev/null +++ b/install_backend_service.ps1 @@ -0,0 +1,112 @@ +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [string]$ServiceName = "ScreenJobBackend", + [string]$DisplayName = "ScreenJob Backend", + [string]$Description = "Runs the ScreenJob backend (start_backend.ps1) as a Windows service.", + [ValidateSet("Automatic", "Manual", "Disabled")] + [string]$StartupType = "Automatic", + [switch]$DelayedAutoStart, + [switch]$ForceReinstall, + [switch]$StartAfterInstall +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not (Test-IsAdministrator)) { + throw "Run this script from an elevated PowerShell session (Run as Administrator)." +} + +$scriptDir = Split-Path -Parent $PSCommandPath +$backendScript = Join-Path $scriptDir "start_backend.ps1" +if (-not (Test-Path -LiteralPath $backendScript)) { + throw "Backend launcher script not found: $backendScript" +} + +$projectFile = Join-Path $scriptDir "service_host\ScreenJob.WindowsServiceHost\ScreenJob.WindowsServiceHost.csproj" +if (-not (Test-Path -LiteralPath $projectFile)) { + throw "Windows service host project not found: $projectFile" +} + +$dotnetCmd = Get-Command dotnet -ErrorAction SilentlyContinue +if ($null -eq $dotnetCmd) { + throw "dotnet SDK was not found in PATH. Install .NET SDK 10+ and retry." +} + +$publishDir = Join-Path $scriptDir "service_host\publish" +$serviceExe = Join-Path $publishDir "ScreenJob.WindowsServiceHost.exe" +$logDir = Join-Path $scriptDir "screenjob_runs\service" + +if ($PSCmdlet.ShouldProcess($projectFile, "Publish Windows service host")) { + & $dotnetCmd.Source publish ` + $projectFile ` + -c Release ` + -r win-x64 ` + --self-contained false ` + -p:PublishSingleFile=true ` + -o $publishDir + + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed with exit code $LASTEXITCODE." + } +} + +if (-not (Test-Path -LiteralPath $serviceExe)) { + throw "Published service executable not found: $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 ` + -BinaryPathName $binaryPath ` + -DisplayName $DisplayName ` + -Description $Description ` + -StartupType $StartupType + + if ($StartupType -eq "Automatic" -and $DelayedAutoStart) { + & sc.exe config $ServiceName start= delayed-auto | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to enable delayed auto-start for '$ServiceName' (sc.exe exit code $LASTEXITCODE)." + } + } + + # Restart on first/second/subsequent failure after 5 seconds. + & sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to configure failure actions for '$ServiceName' (sc.exe exit code $LASTEXITCODE)." + } + + if ($StartAfterInstall) { + Start-Service -Name $ServiceName -ErrorAction Stop + } +} + +Write-Host "Service '$ServiceName' installed successfully." -ForegroundColor Green +Write-Host "Check status with: Get-Service -Name $ServiceName" +Write-Host "View logs in: $logDir" diff --git a/install_tray_startup_shortcut.ps1 b/install_tray_startup_shortcut.ps1 new file mode 100644 index 0000000..eb87cb9 --- /dev/null +++ b/install_tray_startup_shortcut.ps1 @@ -0,0 +1,47 @@ +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [switch]$Remove, + [switch]$AllUsers +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $PSCommandPath +$vbsLauncher = Join-Path $scriptDir "start_screenjob_tray_hidden.vbs" +$shortcutName = "ScreenJob Tray.lnk" + +if (-not (Test-Path -LiteralPath $vbsLauncher)) { + throw "Launcher file not found: $vbsLauncher" +} + +$startupFolder = if ($AllUsers) { + [Environment]::GetFolderPath("CommonStartup") +} else { + [Environment]::GetFolderPath("Startup") +} + +$shortcutPath = Join-Path $startupFolder $shortcutName + +if ($Remove) { + if (Test-Path -LiteralPath $shortcutPath) { + if ($PSCmdlet.ShouldProcess($shortcutPath, "Remove startup shortcut")) { + Remove-Item -LiteralPath $shortcutPath -Force + Write-Host "Removed startup shortcut: $shortcutPath" + } + } else { + Write-Host "No startup shortcut found at: $shortcutPath" + } + return +} + +if ($PSCmdlet.ShouldProcess($shortcutPath, "Create startup shortcut")) { + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = "$env:SystemRoot\System32\wscript.exe" + $shortcut.Arguments = '"' + $vbsLauncher + '"' + $shortcut.WorkingDirectory = $scriptDir + $shortcut.Description = "Launch ScreenJob tray icon at sign-in." + $shortcut.Save() + Write-Host "Created startup shortcut: $shortcutPath" +} diff --git a/screenjob_tray.ps1 b/screenjob_tray.ps1 new file mode 100644 index 0000000..6b795e4 --- /dev/null +++ b/screenjob_tray.ps1 @@ -0,0 +1,210 @@ +param( + [string]$ServiceName = "ScreenJobBackend" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$controlScript = Join-Path $scriptDir "tray_service_control.ps1" +$logsDir = Join-Path $scriptDir "screenjob_runs\service" +$defaultHost = "127.0.0.1" +$defaultPort = "8787" + +function Read-EnvConfig { + param([string]$EnvFilePath) + $result = @{} + if (-not (Test-Path -LiteralPath $EnvFilePath)) { + return $result + } + + foreach ($line in Get-Content -Path $EnvFilePath) { + $trimmed = $line.Trim() + if ($trimmed.Length -eq 0 -or $trimmed.StartsWith("#")) { + continue + } + $parts = $trimmed.Split("=", 2) + if ($parts.Count -eq 2) { + $result[$parts[0].Trim()] = $parts[1].Trim() + } + } + return $result +} + +function Get-ServiceStatusSafe { + param([string]$Name) + try { + $svc = Get-Service -Name $Name -ErrorAction Stop + return $svc.Status.ToString() + } catch { + return "NotInstalled" + } +} + +function Invoke-ServiceActionElevated { + param( + [Parameter(Mandatory = $true)][string]$Action, + [Parameter(Mandatory = $true)][string]$Name + ) + + if (-not (Test-Path -LiteralPath $controlScript)) { + [System.Windows.Forms.MessageBox]::Show( + "Missing control script: $controlScript", + "ScreenJob Tray", + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Error + ) | Out-Null + return + } + + $argList = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", "`"$controlScript`"", + "-Action", $Action, + "-ServiceName", $Name + ) + + try { + Start-Process -FilePath "powershell.exe" -ArgumentList $argList -Verb RunAs -WindowStyle Hidden | Out-Null + } catch { + # User canceled UAC prompt or launch failed. + } +} + +function Get-DashboardUrl { + $envFile = Join-Path $scriptDir ".env" + $envVars = Read-EnvConfig -EnvFilePath $envFile + + $host = $defaultHost + $port = $defaultPort + + if ($envVars.ContainsKey("SCREENJOB_HOST") -and -not [string]::IsNullOrWhiteSpace($envVars["SCREENJOB_HOST"])) { + $host = $envVars["SCREENJOB_HOST"] + } + if ($envVars.ContainsKey("SCREENJOB_PORT") -and -not [string]::IsNullOrWhiteSpace($envVars["SCREENJOB_PORT"])) { + $port = $envVars["SCREENJOB_PORT"] + } + + return "http://{0}:{1}/" -f $host, $port +} + +function Update-TrayState { + param( + [System.Windows.Forms.NotifyIcon]$NotifyIcon, + [System.Windows.Forms.ToolStripMenuItem]$StatusItem, + [string]$Name + ) + + $status = Get-ServiceStatusSafe -Name $Name + $StatusItem.Text = "Status: $status" + + switch ($status) { + "Running" { + $NotifyIcon.Icon = [System.Drawing.SystemIcons]::Information + } + "Stopped" { + $NotifyIcon.Icon = [System.Drawing.SystemIcons]::Warning + } + default { + $NotifyIcon.Icon = [System.Drawing.SystemIcons]::Error + } + } + + $tooltip = "ScreenJob Backend: $status" + if ($tooltip.Length -gt 63) { + $tooltip = $tooltip.Substring(0, 63) + } + $NotifyIcon.Text = $tooltip +} + +$appContext = New-Object System.Windows.Forms.ApplicationContext +$notifyIcon = New-Object System.Windows.Forms.NotifyIcon +$notifyIcon.Visible = $false + +$menu = New-Object System.Windows.Forms.ContextMenuStrip +$statusItem = New-Object System.Windows.Forms.ToolStripMenuItem "Status: Unknown" +$statusItem.Enabled = $false + +$refreshItem = New-Object System.Windows.Forms.ToolStripMenuItem "Refresh Status" +$refreshItem.Add_Click({ + Update-TrayState -NotifyIcon $notifyIcon -StatusItem $statusItem -Name $ServiceName +}) + +$startItem = New-Object System.Windows.Forms.ToolStripMenuItem "Start Service (Admin)" +$startItem.Add_Click({ + Invoke-ServiceActionElevated -Action "start" -Name $ServiceName +}) + +$stopItem = New-Object System.Windows.Forms.ToolStripMenuItem "Stop Service (Admin)" +$stopItem.Add_Click({ + Invoke-ServiceActionElevated -Action "stop" -Name $ServiceName +}) + +$restartItem = New-Object System.Windows.Forms.ToolStripMenuItem "Restart Service (Admin)" +$restartItem.Add_Click({ + Invoke-ServiceActionElevated -Action "restart" -Name $ServiceName +}) + +$dashboardItem = New-Object System.Windows.Forms.ToolStripMenuItem "Open Dashboard" +$dashboardItem.Add_Click({ + $url = Get-DashboardUrl + Start-Process $url | Out-Null +}) + +$logsItem = New-Object System.Windows.Forms.ToolStripMenuItem "Open Service Logs" +$logsItem.Add_Click({ + if (-not (Test-Path -LiteralPath $logsDir)) { + New-Item -ItemType Directory -Path $logsDir -Force | Out-Null + } + Start-Process explorer.exe $logsDir | Out-Null +}) + +$openFolderItem = New-Object System.Windows.Forms.ToolStripMenuItem "Open Project Folder" +$openFolderItem.Add_Click({ + Start-Process explorer.exe $scriptDir | Out-Null +}) + +$exitItem = New-Object System.Windows.Forms.ToolStripMenuItem "Exit Tray" +$exitItem.Add_Click({ + $refreshTimer.Stop() + $notifyIcon.Visible = $false + $notifyIcon.Dispose() + $menu.Dispose() + $appContext.ExitThread() +}) + +[void]$menu.Items.Add($statusItem) +[void]$menu.Items.Add($refreshItem) +[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) +[void]$menu.Items.Add($startItem) +[void]$menu.Items.Add($stopItem) +[void]$menu.Items.Add($restartItem) +[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) +[void]$menu.Items.Add($dashboardItem) +[void]$menu.Items.Add($logsItem) +[void]$menu.Items.Add($openFolderItem) +[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) +[void]$menu.Items.Add($exitItem) + +$notifyIcon.ContextMenuStrip = $menu +$notifyIcon.Visible = $true + +$notifyIcon.Add_DoubleClick({ + $url = Get-DashboardUrl + Start-Process $url | Out-Null +}) + +$refreshTimer = New-Object System.Windows.Forms.Timer +$refreshTimer.Interval = 5000 +$refreshTimer.Add_Tick({ + Update-TrayState -NotifyIcon $notifyIcon -StatusItem $statusItem -Name $ServiceName +}) + +Update-TrayState -NotifyIcon $notifyIcon -StatusItem $statusItem -Name $ServiceName +$refreshTimer.Start() + +[System.Windows.Forms.Application]::Run($appContext) diff --git a/service_host/ScreenJob.WindowsServiceHost/BackendProcessService.cs b/service_host/ScreenJob.WindowsServiceHost/BackendProcessService.cs new file mode 100644 index 0000000..9cb24c0 --- /dev/null +++ b/service_host/ScreenJob.WindowsServiceHost/BackendProcessService.cs @@ -0,0 +1,134 @@ +using System.Diagnostics; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ScreenJob.WindowsServiceHost; + +internal sealed class BackendProcessService : BackgroundService +{ + private readonly ILogger _logger; + private readonly ServiceOptions _options; + private readonly object _logLock = new(); + + private Process? _backendProcess; + private string _stdoutLogPath = string.Empty; + private string _stderrLogPath = string.Empty; + + public BackendProcessService(ILogger logger, ServiceOptions options) + { + _logger = logger; + _options = options; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + Directory.CreateDirectory(_options.LogDirectory); + _stdoutLogPath = Path.Combine(_options.LogDirectory, "backend-service.stdout.log"); + _stderrLogPath = Path.Combine(_options.LogDirectory, "backend-service.stderr.log"); + + LogStdOut("Service host starting backend process."); + LogStdOut($"Script: {_options.BackendScriptPath}"); + LogStdOut($"Working directory: {_options.WorkingDirectory}"); + + var powershellPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + + var startInfo = new ProcessStartInfo + { + FileName = powershellPath, + Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{_options.BackendScriptPath}\"", + WorkingDirectory = _options.WorkingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + _backendProcess = new Process { StartInfo = startInfo }; + if (!_backendProcess.Start()) + { + throw new InvalidOperationException("Failed to start backend process."); + } + + LogStdOut($"Backend process started with PID {_backendProcess.Id}."); + _logger.LogInformation("Backend process started with PID {Pid}.", _backendProcess.Id); + + var stdoutPump = PumpStreamAsync(_backendProcess.StandardOutput, LogStdOut, stoppingToken); + var stderrPump = PumpStreamAsync(_backendProcess.StandardError, LogStdErr, stoppingToken); + + try + { + await _backendProcess.WaitForExitAsync(stoppingToken); + LogStdOut($"Backend process exited with code {_backendProcess.ExitCode}."); + _logger.LogWarning("Backend process exited with code {ExitCode}.", _backendProcess.ExitCode); + } + catch (OperationCanceledException) + { + LogStdOut("Service stop requested."); + } + finally + { + await Task.WhenAll(stdoutPump, stderrPump); + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + if (_backendProcess is { HasExited: false }) + { + try + { + LogStdOut("Stopping backend process."); + _backendProcess.Kill(entireProcessTree: true); + } + catch (Exception ex) + { + LogStdErr($"Failed to stop backend process cleanly: {ex.Message}"); + _logger.LogError(ex, "Failed to stop backend process cleanly."); + } + } + + await base.StopAsync(cancellationToken); + } + + private async Task PumpStreamAsync( + StreamReader reader, + Action sink, + CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(); + if (line is null) + { + break; + } + + sink(line); + } + } + + private void LogStdOut(string message) + { + WriteLog(_stdoutLogPath, message); + } + + private void LogStdErr(string message) + { + WriteLog(_stderrLogPath, message); + } + + private void WriteLog(string path, string message) + { + var stamp = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss"); + var line = $"[{stamp}] {message}{Environment.NewLine}"; + lock (_logLock) + { + File.AppendAllText(path, line); + } + } +} diff --git a/service_host/ScreenJob.WindowsServiceHost/Program.cs b/service_host/ScreenJob.WindowsServiceHost/Program.cs new file mode 100644 index 0000000..66177f6 --- /dev/null +++ b/service_host/ScreenJob.WindowsServiceHost/Program.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ScreenJob.WindowsServiceHost; + +var options = ServiceOptions.Parse(args); + +Host.CreateDefaultBuilder(args) + .UseWindowsService(serviceOptions => + { + serviceOptions.ServiceName = "ScreenJobBackend"; + }) + .ConfigureServices(services => + { + services.AddSingleton(options); + services.AddHostedService(); + }) + .Build() + .Run(); diff --git a/service_host/ScreenJob.WindowsServiceHost/ScreenJob.WindowsServiceHost.csproj b/service_host/ScreenJob.WindowsServiceHost/ScreenJob.WindowsServiceHost.csproj new file mode 100644 index 0000000..f05a01a --- /dev/null +++ b/service_host/ScreenJob.WindowsServiceHost/ScreenJob.WindowsServiceHost.csproj @@ -0,0 +1,12 @@ + + + net10.0-windows + enable + enable + Exe + + + + + + diff --git a/service_host/ScreenJob.WindowsServiceHost/ServiceOptions.cs b/service_host/ScreenJob.WindowsServiceHost/ServiceOptions.cs new file mode 100644 index 0000000..9c940b9 --- /dev/null +++ b/service_host/ScreenJob.WindowsServiceHost/ServiceOptions.cs @@ -0,0 +1,77 @@ +namespace ScreenJob.WindowsServiceHost; + +internal sealed record ServiceOptions( + string BackendScriptPath, + string WorkingDirectory, + string LogDirectory) +{ + public static ServiceOptions Parse(string[] args) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < args.Length; i++) + { + var raw = args[i]; + if (!raw.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + var key = raw[2..]; + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal)) + { + map[key] = args[++i]; + } + else + { + map[key] = "true"; + } + } + + if (!map.TryGetValue("backend-script", out var backendScript) || string.IsNullOrWhiteSpace(backendScript)) + { + throw new ArgumentException("Missing required argument: --backend-script ."); + } + + if (!Path.IsPathRooted(backendScript)) + { + throw new ArgumentException("The --backend-script value must be an absolute path."); + } + + if (!File.Exists(backendScript)) + { + throw new FileNotFoundException("Backend script not found.", backendScript); + } + + if (!map.TryGetValue("working-dir", out var workingDir) || string.IsNullOrWhiteSpace(workingDir)) + { + workingDir = Path.GetDirectoryName(backendScript) + ?? throw new ArgumentException("Could not resolve working directory from backend script path."); + } + + if (!Path.IsPathRooted(workingDir)) + { + throw new ArgumentException("The --working-dir value must be an absolute path."); + } + + if (!map.TryGetValue("log-dir", out var logDir) || string.IsNullOrWhiteSpace(logDir)) + { + logDir = Path.Combine(workingDir, "screenjob_runs", "service"); + } + + if (!Path.IsPathRooted(logDir)) + { + throw new ArgumentException("The --log-dir value must be an absolute path."); + } + + return new ServiceOptions( + Path.GetFullPath(backendScript), + Path.GetFullPath(workingDir), + Path.GetFullPath(logDir)); + } +} diff --git a/start_backend.ps1 b/start_backend.ps1 index 467ecd5..4b1992a 100644 --- a/start_backend.ps1 +++ b/start_backend.ps1 @@ -16,7 +16,22 @@ function Test-EnvVarLine { } if (-not (Get-Command python -ErrorAction SilentlyContinue)) { - throw "Python was not found in PATH. Install Python 3.11+ and retry." + $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." + } +} + +$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 } $envFile = Join-Path $scriptDir ".env" @@ -32,4 +47,4 @@ if (-not (Test-Path -LiteralPath $envFile)) { } Write-Host "Starting ScreenJob backend on configured host/port..." -ForegroundColor Cyan -python main.py server +& $pythonExe main.py server diff --git a/start_screenjob_tray_hidden.vbs b/start_screenjob_tray_hidden.vbs new file mode 100644 index 0000000..f9ba060 --- /dev/null +++ b/start_screenjob_tray_hidden.vbs @@ -0,0 +1,11 @@ +Option Explicit + +Dim shell, fso, scriptDir, psScript, command +Set shell = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") + +scriptDir = fso.GetParentFolderName(WScript.ScriptFullName) +psScript = """" & fso.BuildPath(scriptDir, "screenjob_tray.ps1") & """" + +command = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -STA -File " & psScript +shell.Run command, 0, False diff --git a/tray_service_control.ps1 b/tray_service_control.ps1 new file mode 100644 index 0000000..d77fa7f --- /dev/null +++ b/tray_service_control.ps1 @@ -0,0 +1,53 @@ +[CmdletBinding()] +param( + [ValidateSet("start", "stop", "restart")] + [string]$Action, + [string]$ServiceName = "ScreenJobBackend" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Wait-ForStatus { + param( + [Parameter(Mandatory = $true)]$Service, + [Parameter(Mandatory = $true)][System.ServiceProcess.ServiceControllerStatus]$TargetStatus, + [int]$TimeoutSeconds = 20 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $Service.Refresh() + if ($Service.Status -eq $TargetStatus) { + return + } + Start-Sleep -Milliseconds 350 + } + + throw "Timed out waiting for service '$($Service.ServiceName)' to reach status '$TargetStatus'." +} + +$service = Get-Service -Name $ServiceName -ErrorAction Stop + +switch ($Action) { + "start" { + if ($service.Status -ne [System.ServiceProcess.ServiceControllerStatus]::Running) { + Start-Service -Name $ServiceName -ErrorAction Stop + Wait-ForStatus -Service $service -TargetStatus ([System.ServiceProcess.ServiceControllerStatus]::Running) + } + } + "stop" { + if ($service.Status -ne [System.ServiceProcess.ServiceControllerStatus]::Stopped) { + Stop-Service -Name $ServiceName -Force -ErrorAction Stop + Wait-ForStatus -Service $service -TargetStatus ([System.ServiceProcess.ServiceControllerStatus]::Stopped) + } + } + "restart" { + if ($service.Status -eq [System.ServiceProcess.ServiceControllerStatus]::Running) { + Restart-Service -Name $ServiceName -Force -ErrorAction Stop + } else { + Start-Service -Name $ServiceName -ErrorAction Stop + } + Wait-ForStatus -Service $service -TargetStatus ([System.ServiceProcess.ServiceControllerStatus]::Running) + } +} diff --git a/uninstall_backend_service.ps1 b/uninstall_backend_service.ps1 new file mode 100644 index 0000000..025f898 --- /dev/null +++ b/uninstall_backend_service.ps1 @@ -0,0 +1,36 @@ +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [string]$ServiceName = "ScreenJobBackend" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not (Test-IsAdministrator)) { + throw "Run this script from an elevated PowerShell session (Run as Administrator)." +} + +$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($null -eq $service) { + Write-Host "Service '$ServiceName' is not installed." + exit 0 +} + +if ($PSCmdlet.ShouldProcess($ServiceName, "Uninstall service")) { + if ($service.Status -ne "Stopped") { + Stop-Service -Name $ServiceName -Force -ErrorAction Stop + } + + & sc.exe delete $ServiceName | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to delete service '$ServiceName' (sc.exe exit code $LASTEXITCODE)." + } +} + +Write-Host "Service '$ServiceName' uninstalled successfully." -ForegroundColor Green