use std::os::windows::process::CommandExt;
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use super::Registration;
pub const SUPPORTED: bool = true;
const TASK: &str = "Ralon Supervisor";
const NO_WINDOW: u32 = 0x0800_0000;
fn schtasks(arguments: &[&str]) -> Command {
let mut command = Command::new("schtasks");
command.args(arguments).creation_flags(NO_WINDOW);
command
}
pub fn install(executable: &Path, home: &Path) -> Result<Registration> {
let user = account();
let xml = describe_task(executable, home, &user);
let path = std::env::temp_dir().join("ralon-supervisor-task.xml");
std::fs::write(&path, utf16(&xml))
.with_context(|| format!("failed to write {}", path.display()))?;
let created = schtasks(&["/Create", "/TN", TASK, "/XML"])
.arg(&path)
.arg("/F")
.output()
.context("failed to run schtasks — is it on PATH?")?;
let _ = std::fs::remove_file(&path);
if !created.status.success() {
anyhow::bail!(
"schtasks refused to register the supervisor: {}",
message(&created)
);
}
let mut warnings = Vec::new();
let started = schtasks(&["/Run", "/TN", TASK])
.output()
.context("failed to run schtasks")?;
if !started.status.success() {
warnings.push(format!(
"the task is registered but would not start now ({}) — it will start at \
the next logon, or run `ralon daemon` in a terminal to see why",
message(&started)
));
}
Ok(Registration {
mechanism: "a Task Scheduler logon task",
path: None,
warnings,
})
}
pub fn uninstall() -> Result<bool> {
if !installed() {
return Ok(false);
}
let _ = schtasks(&["/End", "/TN", TASK]).output();
let removed = schtasks(&["/Delete", "/TN", TASK, "/F"])
.output()
.context("failed to run schtasks")?;
if !removed.status.success() {
anyhow::bail!(
"schtasks would not remove the supervisor task: {}",
message(&removed)
);
}
Ok(true)
}
pub fn installed() -> bool {
schtasks(&["/Query", "/TN", TASK])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn unsupported_reason() -> String {
String::new()
}
fn account() -> String {
let user = std::env::var("USERNAME").unwrap_or_default();
let domain = std::env::var("USERDOMAIN")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_default();
if domain.is_empty() {
user
} else {
format!("{domain}\\{user}")
}
}
fn describe_task(executable: &Path, home: &Path, user: &str) -> String {
let command = escape(&executable.display().to_string());
let arguments = escape(&format!("daemon --home \"{}\"", home.display()));
let user = escape(user);
format!(
r#"<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>Ralon enforces agent.lock in the workspaces registered with `ralon install`.</Description>
<URI>\{TASK}</URI>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<UserId>{user}</UserId>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>{user}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>true</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
<RestartOnFailure>
<Interval>PT1M</Interval>
<Count>3</Count>
</RestartOnFailure>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{command}</Command>
<Arguments>{arguments}</Arguments>
</Exec>
</Actions>
</Task>
"#
)
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn utf16(text: &str) -> Vec<u8> {
let mut bytes = vec![0xFF, 0xFE];
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
bytes
}
fn message(output: &std::process::Output) -> String {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
let trimmed = combined.trim();
if trimmed.is_empty() {
format!("exit code {}", output.status.code().unwrap_or(-1))
} else {
trimmed.replace('\r', "").replace('\n', " ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_task_never_expires() {
let xml = describe_task(
Path::new("C:\\ralon.exe"),
Path::new("C:\\state"),
"PC\\dev",
);
assert!(
xml.contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"),
"{xml}"
);
assert!(xml.contains("<Hidden>true</Hidden>"), "{xml}");
assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
}
#[test]
fn the_state_directory_is_passed_rather_than_inherited() {
let xml = describe_task(
Path::new("C:\\ralon.exe"),
Path::new("C:\\state"),
"PC\\dev",
);
assert!(xml.contains("daemon --home "C:\\state""), "{xml}");
}
#[test]
fn markup_in_a_path_cannot_break_out_of_the_element() {
let xml = describe_task(
Path::new("C:\\a&b\\ralon.exe"),
Path::new("C:\\state"),
"PC\\dev",
);
assert!(xml.contains("C:\\a&b\\ralon.exe"), "{xml}");
}
#[test]
fn the_document_is_utf16_with_a_byte_order_mark() {
let bytes = utf16("<a/>");
assert_eq!(&bytes[..2], &[0xFF, 0xFE]);
assert_eq!(&bytes[2..4], &[b'<', 0]);
}
}