Add Windows service host and system tray controller
All checks were successful
CI / test (push) Successful in 7s
All checks were successful
CI / test (push) Successful in 7s
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -20,3 +20,8 @@ screenjob.db
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Service host build/publish artifacts
|
||||
service_host/**/bin/
|
||||
service_host/**/obj/
|
||||
service_host/publish/
|
||||
|
||||
71
README.md
71
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 <SCREENJOB_TOKEN>`
|
||||
|
||||
112
install_backend_service.ps1
Normal file
112
install_backend_service.ps1
Normal file
@@ -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"
|
||||
47
install_tray_startup_shortcut.ps1
Normal file
47
install_tray_startup_shortcut.ps1
Normal file
@@ -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"
|
||||
}
|
||||
210
screenjob_tray.ps1
Normal file
210
screenjob_tray.ps1
Normal file
@@ -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)
|
||||
@@ -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<BackendProcessService> _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<BackendProcessService> 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<string> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
service_host/ScreenJob.WindowsServiceHost/Program.cs
Normal file
18
service_host/ScreenJob.WindowsServiceHost/Program.cs
Normal file
@@ -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<BackendProcessService>();
|
||||
})
|
||||
.Build()
|
||||
.Run();
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
77
service_host/ScreenJob.WindowsServiceHost/ServiceOptions.cs
Normal file
77
service_host/ScreenJob.WindowsServiceHost/ServiceOptions.cs
Normal file
@@ -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<string, string>(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 <absolute-path-to-start_backend.ps1>.");
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
11
start_screenjob_tray_hidden.vbs
Normal file
11
start_screenjob_tray_hidden.vbs
Normal file
@@ -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
|
||||
53
tray_service_control.ps1
Normal file
53
tray_service_control.ps1
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
36
uninstall_backend_service.ps1
Normal file
36
uninstall_backend_service.ps1
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user