diff --git a/Cargo.lock b/Cargo.lock
index 79da5e12..c5901013 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4519,6 +4519,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"uuid",
+ "windows-service",
"windows-sys 0.61.2",
"zip 9.0.5",
]
@@ -7830,6 +7831,12 @@ dependencies = [
"safe_arch",
]
+[[package]]
+name = "widestring"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -7980,6 +7987,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-service"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2"
+dependencies = [
+ "bitflags 2.13.2",
+ "widestring",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "windows-strings"
version = "0.5.1"
diff --git a/Cargo.toml b/Cargo.toml
index ab4ea367..d66c4ac7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -117,6 +117,9 @@ schemars = "1"
# macOS memory probing, and signalling the process group a WBPP run starts.
libc = "0.2"
[target.'cfg(target_os = "windows")'.dependencies]
+# The worker as a Windows service: a thin wrapper of the Service Control
+# Manager API.
+windows-service = "0.8"
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
diff --git a/docs/REMOTE_WORKERS.md b/docs/REMOTE_WORKERS.md
index 1fd53521..a19b1f92 100644
--- a/docs/REMOTE_WORKERS.md
+++ b/docs/REMOTE_WORKERS.md
@@ -162,6 +162,84 @@ again every 30 seconds; `journalctl -u psf-guard-worker` shows why.
`PSF_GUARD_WORKER_EXTRA_ARGS` is split on spaces, so it cannot hold a path
that contains one.
+## Run a worker as a Windows service
+
+Use a Windows service when the worker needs to run before anyone logs in.
+The service starts with Windows. It uses its own copies of the app and
+settings, separate from the desktop worker.
+
+1. Open **Settings › Workers › Worker on this computer** in the desktop
+ app and enter the worker settings. Turn off **Start worker at login**,
+ then click **Save worker settings**. If the desktop worker is running,
+ click **Stop worker** before installing the service.
+2. Open PowerShell as administrator and run:
+
+ ```powershell
+ psf-guard-cli worker-service install
+ ```
+
+ To use a different settings file, add `--config FILE`.
+
+The command installs and starts the **PSF Guard Worker** service. It copies
+the executable to `%ProgramFiles%\PSF Guard Worker` and settings to
+`%ProgramData%\PSF Guard Worker\worker.json`. SYSTEM, Administrators and
+the service account can access the service's data folder. The settings
+include the worker token.
+
+The service runs under its own account, `NT SERVICE\PSFGuardWorker`.
+Task files use `%ProgramData%\PSF Guard Worker\tasks`; the desktop app's
+**Temporary task folder** setting does not change the service's task folder.
+
+### Apply settings or app updates to the service
+
+After changing worker settings, click **Save worker settings**. After
+saving settings or updating PSF Guard, open PowerShell as administrator and
+run `psf-guard-cli worker-service install` again. The command stops the
+service, updates its copies of the app and settings, and restarts it.
+
+**Restart worker** controls the desktop worker. It does not restart or
+update the Windows service. While the service is running or changing state,
+**Start worker** is disabled to prevent a second worker from starting.
+
+### Check service logs and requirements
+
+- **Logs:** filenames start with `worker` in
+ `%ProgramData%\PSF Guard Worker\logs`.
+- **Access denied:** if the service cannot read `worker.json`, update PSF Guard
+ and run `psf-guard-cli worker-service install` from PowerShell opened as
+ administrator. This repairs the service folder's permissions and starts
+ the worker.
+- **Failures:** if the server refuses the token, the service logs the reason
+ and stops. Windows restarts it after 30 seconds. Replace a refused token
+ in the desktop settings and run the install command again.
+- **Stopping:** stop the service in Windows Services. The worker disconnects
+ and the server queues unfinished tasks again. Windows shutdown also gives
+ the worker time to disconnect.
+- **Sleep:** prevent the computer from sleeping while you need it to process
+ tasks. A worker cannot process tasks while Windows is asleep.
+- **RC-Astro tools:** the service uses rc-astro from its account's `PATH`,
+ or the executable specified in its settings. An installation or license
+ available only to your desktop account may be unavailable to the service.
+ If the service cannot run RC-Astro, it can still accept stack builds.
+ Use the desktop worker if RC-Astro requires your desktop account.
+- **Shared storage:** the default service account accesses network shares
+ through the computer's network identity. If a share requires a user
+ account, change the service account in Windows Services to a user with
+ access to that share. Give that user modify access to
+ `%ProgramData%\PSF Guard Worker`. Enter folder mappings with UNC paths,
+ such as `\\nas\astro`, that the service account can access.
+ Reinstalling restores the default service account. After reinstalling,
+ stop the service, reapply your custom account, and start the service in
+ Windows Services.
+
+### Remove the Windows service
+
+Before uninstalling PSF Guard, open PowerShell as administrator and run
+`psf-guard-cli worker-service uninstall`. This stops and removes the service
+and deletes its copied executable, settings including the token, task files,
+and logs. The server queues unfinished tasks again. The desktop app's
+saved worker settings remain in its own settings folder.
+
## Choose the work a worker accepts
A worker accepts everything it can run unless you list its work with
diff --git a/docs/design/remote-workers.md b/docs/design/remote-workers.md
index cba2e25c..a8c2456e 100644
--- a/docs/design/remote-workers.md
+++ b/docs/design/remote-workers.md
@@ -219,6 +219,27 @@ and registers no plugins.
- **Desktop app.** `src/desktop_worker.rs` has the commands behind
**Settings › Workers › Worker on this computer**. They read and save settings,
start the tray process, and pause, resume and stop it through the control port.
+- **Windows service** (`commands/worker/service.rs`). Service startup uses
+ copies that unprivileged processes cannot modify. `worker-service install`
+ copies the executable to `%ProgramFiles%\PSF Guard Worker\psf-guard-worker.exe`
+ and settings to `%ProgramData%\PSF Guard Worker\worker.json`. It sets
+ `work_dir` to the service's own `tasks` folder. The service runs as the
+ virtual account `NT SERVICE\PSFGuardWorker`.
+ `icacls` restricts the data folder to SYSTEM, Administrators and that
+ account. Settings and task files inherit the folder's permissions;
+ reinstalling repairs permissions left by earlier installs.
+ Running install again stops the service, updates its copies and
+ configuration, and restarts it. Installation and removal wait for the
+ stopped service's process to exit before replacing or deleting its
+ executable. Startup is automatic and delayed. During preshutdown, Windows
+ allows up to 60 seconds for the worker to disconnect; the service reports
+ `StopPending` with an increasing checkpoint every five seconds.
+ A failed run logs its reason and exits with a service-specific code;
+ Windows restarts it after 30 seconds. `worker-service uninstall` stops
+ and removes the service, then deletes its executable, settings, task files
+ and logs. The desktop app disables **Start worker** until the service
+ stops. A Windows service state does not prove a connection to the
+ processing server, so the UI reports it separately.
- **Start worker at login** (`commands/worker/login.rs`): a LaunchAgent with
`RunAtLoad` on macOS, and a value under
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run` on Windows. Both
diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md
index 7f6a4b38..d89634df 100644
--- a/docs/releases/unreleased.md
+++ b/docs/releases/unreleased.md
@@ -19,6 +19,11 @@
- FITS downloads in stack processing use compact controls with a download icon
and explicit labels. Long labels wrap to fit narrow panels.
+- Windows worker services can now read their saved settings and start
+ successfully. Reinstalling the service also repairs permissions left by an
+ earlier installation and waits for the stopped worker process before
+ replacing its executable.
+
- A fresh flat that contributes to a light's master now provides coverage even
when older flats in the same master move the median capture time beyond
the **Flat age limit**. Age checks and the calibration report use the nearest
@@ -38,6 +43,13 @@
you log in. Opening PSF Guard while its window is open brings that window
forward instead of starting a second copy.
+- Run a worker as a Windows service to process tasks before anyone logs in.
+ Open **Settings › Workers › Worker on this computer**, click **Save worker
+ settings**, then run `psf-guard-cli worker-service install` in PowerShell
+ opened as administrator. Run the command again after changing settings or
+ updating PSF Guard to update and restart the service. Desktop worker
+ controls do not control the service.
+
- Run a worker as a Linux service. The Fedora RPM and the Debian package
install a `psf-guard-worker` systemd service, not enabled. Enter the
server's address and a worker token in `/etc/psf-guard/worker.conf`, then
diff --git a/src/cli.rs b/src/cli.rs
index 77f10ea9..48d74195 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -14,7 +14,27 @@ pub struct Cli {
pub command: Commands,
}
-/// CLI spelling of [`crate::server::stack_preview::snr::StackFrameOrder`].
+/// What `psf-guard worker-service` does.
+#[derive(Subcommand, Debug)]
+pub enum WorkerServiceAction {
+ /// Install the service, and start it.
+ Install {
+ /// The worker's settings file (defaults to the one the desktop app
+ /// saves for this user).
+ #[arg(long, value_name = "FILE")]
+ config: Option,
+ },
+ /// Stop and remove the service. Delete its copied executable, settings,
+ /// task files and logs.
+ Uninstall,
+ /// Run as the service. Windows starts this; it is not for a terminal.
+ #[command(hide = true)]
+ Run {
+ #[arg(long, value_name = "FILE")]
+ config: String,
+ },
+}
+
/// How a worker reaches task files.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum WorkerFilesArg {
@@ -25,6 +45,7 @@ pub enum WorkerFilesArg {
Shared,
}
+/// CLI spelling of [`crate::server::stack_preview::snr::StackFrameOrder`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum StackOrderArg {
/// Chronological.
@@ -1388,6 +1409,18 @@ pub enum Commands {
slots: usize,
},
+ /// Run the worker as a Windows service, for a computer that runs work
+ /// while nobody is logged in.
+ ///
+ /// Save the worker's settings in the desktop app's Settings › Workers ›
+ /// This computer first, or write a settings file. Then, from a terminal opened
+ /// as administrator, run `psf-guard-cli worker-service install`. The
+ /// service runs as Local System and starts with Windows.
+ WorkerService {
+ #[command(subcommand)]
+ action: WorkerServiceAction,
+ },
+
/// Start the web server for API access and static file serving
Server {
/// Path to TOML configuration file
diff --git a/src/cli_main.rs b/src/cli_main.rs
index f1abd13b..4d3c8f5c 100644
--- a/src/cli_main.rs
+++ b/src/cli_main.rs
@@ -1639,6 +1639,29 @@ pub fn main() -> Result<()> {
))?;
}
}
+ Commands::WorkerService { action } => {
+ use crate::cli::WorkerServiceAction;
+ use crate::commands::worker::{service, WorkerSettings};
+ match action {
+ WorkerServiceAction::Install { config } => {
+ let path = match config {
+ Some(config) => std::path::PathBuf::from(config),
+ None => WorkerSettings::default_path()?,
+ };
+ service::install(&path)?;
+ println!(
+ "The PSF Guard Worker service runs with a copy of the settings in {}. \
+ After you change them, or update PSF Guard, run this command again.",
+ path.display()
+ );
+ }
+ WorkerServiceAction::Uninstall => {
+ service::uninstall()?;
+ println!("Stopped and removed the PSF Guard Worker service");
+ }
+ WorkerServiceAction::Run { config } => service::run(config.into())?,
+ }
+ }
Commands::Users { action } => {
crate::commands::users::manage_users(action)?;
}
diff --git a/src/commands/worker.rs b/src/commands/worker.rs
index 7aead73c..081cba6c 100644
--- a/src/commands/worker.rs
+++ b/src/commands/worker.rs
@@ -27,6 +27,7 @@ use crate::remote_work::{file_digest, map_path, LocalTools, WorkChoice};
pub mod control;
pub mod login;
+pub mod service;
pub mod settings;
pub use control::{WorkerControl, WorkerStatus};
diff --git a/src/commands/worker/service.rs b/src/commands/worker/service.rs
new file mode 100644
index 00000000..229af35f
--- /dev/null
+++ b/src/commands/worker/service.rs
@@ -0,0 +1,510 @@
+//! `psf-guard worker-service`: the worker as a Windows service, for a
+//! computer that runs work while nobody is logged in.
+//!
+//! The app installs per user, in a folder its user can change, so the
+//! service never runs the app's own files. `install` copies the binary to
+//! `%ProgramFiles%\PSF Guard Worker` and the settings to
+//! `%ProgramData%\PSF Guard Worker`, which only administrators and the
+//! service's account can reach, and runs the service as its own virtual
+//! account, `NT SERVICE\PSFGuardWorker`, rather than Local System. Running
+//! `install` again updates the copies, such as after the app is updated.
+
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result};
+
+#[cfg(windows)]
+mod permissions;
+#[cfg(windows)]
+mod process;
+
+/// The service's name, as `sc.exe` and the Services console identify it.
+#[cfg_attr(not(windows), allow(dead_code))]
+const SERVICE_NAME: &str = "PSFGuardWorker";
+#[cfg_attr(not(windows), allow(dead_code))]
+const DISPLAY_NAME: &str = "PSF Guard Worker";
+#[cfg_attr(not(windows), allow(dead_code))]
+const DESCRIPTION: &str = "Runs stack builds and RC-Astro tools for a PSF Guard server.";
+/// The folder under Program Files for the binary, and under ProgramData for
+/// the settings, the task files and the logs.
+#[cfg_attr(not(windows), allow(dead_code))]
+const FOLDER: &str = "PSF Guard Worker";
+
+/// Install the service with the settings at `settings`, or update it, and
+/// start it.
+pub fn install(settings: &Path) -> Result<()> {
+ let settings =
+ std::path::absolute(settings).with_context(|| format!("finding {}", settings.display()))?;
+ let loaded = super::WorkerSettings::load(&settings)?.ok_or_else(|| {
+ anyhow::anyhow!(
+ "No worker settings file was found at {}. Save worker settings in PSF Guard's \
+ Settings › Workers › Worker on this computer, or choose a file with --config FILE",
+ settings.display()
+ )
+ })?;
+ loaded.validate()?;
+ platform::install(loaded)
+}
+
+/// Stop the service and remove it, with its copies of the binary and the
+/// settings.
+pub fn uninstall() -> Result<()> {
+ platform::uninstall()
+}
+
+/// Run as the service. Windows starts this; it is not for a terminal.
+pub fn run(settings: PathBuf) -> Result<()> {
+ platform::run(settings)
+}
+
+/// Whether the service is installed, and whether it runs: for the desktop
+/// app, which shows a service worker in place of its own.
+pub fn state() -> Option {
+ platform::state()
+}
+
+/// What the desktop app shows about the service.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ServiceState {
+ Running,
+ Stopped,
+ /// Starting, stopping, paused or resuming.
+ Changing,
+}
+
+/// The settings the service runs with: the saved ones, with the service's
+/// own task folder.
+#[cfg_attr(not(windows), allow(dead_code))]
+fn service_settings(mut settings: super::WorkerSettings, tasks: &Path) -> super::WorkerSettings {
+ settings.work_dir = Some(tasks.display().to_string());
+ settings
+}
+
+/// The arguments the service starts the binary with.
+#[cfg_attr(not(windows), allow(dead_code))]
+fn launch_arguments(settings: &Path) -> Vec {
+ vec![
+ "worker-service".into(),
+ "run".into(),
+ "--config".into(),
+ settings.as_os_str().to_owned(),
+ ]
+}
+
+#[cfg(windows)]
+mod platform {
+ use std::ffi::OsString;
+ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
+ use std::sync::{Arc, OnceLock};
+ use std::time::Duration;
+
+ use windows_service::service::{
+ Service, ServiceAccess, ServiceAction, ServiceActionType, ServiceControl,
+ ServiceControlAccept, ServiceErrorControl, ServiceExitCode, ServiceFailureActions,
+ ServiceFailureResetPeriod, ServiceInfo, ServiceStartType, ServiceState as State,
+ ServiceStatus, ServiceType,
+ };
+ use windows_service::service_control_handler::{
+ self, ServiceControlHandlerResult, ServiceStatusHandle,
+ };
+ use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
+ use windows_service::{define_windows_service, service_dispatcher};
+
+ use super::*;
+ use crate::commands::worker::{run_with, WorkerControl, WorkerLaunch};
+
+ /// How long the worker may take to stop: its tool stops within 30
+ /// seconds, and it then leaves the server.
+ const STOP_WAIT: Duration = Duration::from_secs(60);
+ /// How long after a failure Windows starts the service again.
+ const RESTART_AFTER: Duration = Duration::from_secs(30);
+ /// How often a stop reports that it is still going.
+ const STOP_PROGRESS: Duration = Duration::from_secs(5);
+
+ const ERROR_ACCESS_DENIED: i32 = 5;
+ const ERROR_SERVICE_DOES_NOT_EXIST: i32 = 1060;
+ const REFUSED: &str = "Administrator access is required to manage the PSF Guard Worker service. Open PowerShell as administrator and run the command again.";
+
+ static SETTINGS: OnceLock = OnceLock::new();
+
+ define_windows_service!(ffi_service_main, service_main);
+
+ /// Where the service's binary goes: only administrators can change it.
+ fn binary() -> Result {
+ let folder = std::env::var_os("ProgramFiles")
+ .map(PathBuf::from)
+ .context("Windows did not report the Program Files folder")?;
+ Ok(folder.join(FOLDER).join("psf-guard-worker.exe"))
+ }
+
+ /// The service's data folder: its settings, task files and logs.
+ fn data() -> PathBuf {
+ std::env::var_os("ProgramData")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
+ .join(FOLDER)
+ }
+
+ fn os_error(error: &windows_service::Error) -> Option {
+ match error {
+ windows_service::Error::Winapi(error) => error.raw_os_error(),
+ _ => None,
+ }
+ }
+
+ fn manager(access: ServiceManagerAccess) -> Result {
+ ServiceManager::local_computer(None::<&str>, access).map_err(|error| {
+ if os_error(&error) == Some(ERROR_ACCESS_DENIED) {
+ anyhow::anyhow!(REFUSED)
+ } else {
+ anyhow::Error::new(error).context("opening the Windows services")
+ }
+ })
+ }
+
+ /// The installed service, or `None` when there is none.
+ fn open(manager: &ServiceManager, access: ServiceAccess) -> Result
-
{status ? statusText(status) : 'Not running.'}
+
+ {status
+ ? statusText(status)
+ : loaded.service === 'running'
+ ? 'The PSF Guard Worker Windows service is running.'
+ : serviceRuns
+ ? 'The PSF Guard Worker Windows service has not stopped. Stop it in Windows Services to use the desktop worker.'
+ : 'Not running.'}
+
{status?.tasks.map((task) => (
{task.label}: {Math.round(task.fraction * 100)}%
))}
+ {serviceRuns && !status && (
+
+ To update the service, click Save worker settings, then run{' '}
+ psf-guard-cli worker-service install in PowerShell opened
+ as administrator. This command updates the service's copies of
+ the app and settings, then restarts the service. The desktop worker
+ controls do not control the Windows service.
+