use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub const LABEL: &str = "com.semlith.daemon";
pub const SERVICE_ENV: &str = "SEMLITH_SERVICE";
pub fn running_as_service() -> bool {
std::env::var_os(SERVICE_ENV).is_some_and(|v| v == "1")
|| std::env::var_os("XPC_SERVICE_NAME").is_some_and(|v| v == LABEL)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Status {
pub installed: bool,
pub mechanism: &'static str,
pub definition: Option<PathBuf>,
pub log: Option<PathBuf>,
pub restarts: bool,
pub started_now: bool,
}
impl Status {
fn absent(mechanism: &'static str) -> Self {
Self {
installed: false,
mechanism,
definition: None,
log: None,
restarts: false,
started_now: false,
}
}
}
pub fn log_path() -> Result<PathBuf> {
let dir = crate::home::home_or_error()?.join("logs");
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
crate::home::tighten_dir(&dir);
let log = dir.join("daemon.log");
if !log.exists() {
let _ = crate::home::write_private(&log, b"");
}
crate::home::tighten_file(&log);
Ok(log)
}
#[cfg(any(target_os = "linux", test))]
fn systemd_quote(raw: &str) -> String {
let escaped = raw
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('%', "%%")
.replace('$', "$$");
format!("\"{escaped}\"")
}
pub fn exe() -> Result<PathBuf> {
let exe = std::env::current_exe().context("finding this binary")?;
Ok(plain(exe.canonicalize().unwrap_or(exe)))
}
fn plain(path: PathBuf) -> PathBuf {
let text = path.to_string_lossy();
match text.strip_prefix(r"\\?\UNC\") {
Some(rest) => PathBuf::from(format!(r"\\{rest}")),
None => match text.strip_prefix(r"\\?\") {
Some(rest) => PathBuf::from(rest),
None => path,
},
}
}
#[cfg(target_os = "macos")]
mod platform {
use super::*;
pub const MECHANISM: &str = "launchd";
pub const PROCESS_TYPE: &str = "Standard";
pub fn stale_definition() -> Option<String> {
let path = plist_path().ok()?;
let text = std::fs::read_to_string(&path).ok()?;
text.contains("<string>Background</string>").then(|| {
format!(
"{} asks launchd for ProcessType Background, which holds the daemon on the \
efficiency cores; run `semlith setup` (or `semlith start --service`) to rewrite it",
path.display()
)
})
}
fn plist_path() -> Result<PathBuf> {
Ok(crate::home::user_home()?
.join("Library")
.join("LaunchAgents")
.join(format!("{LABEL}.plist")))
}
pub fn status() -> Status {
let Ok(path) = plist_path() else {
return Status::absent(MECHANISM);
};
let known = path.is_file()
&& run(
"launchctl",
&[
"print",
&format!("gui/{}/{LABEL}", unsafe { libc::getuid() }),
],
)
.is_ok();
Status {
installed: known,
definition: path.is_file().then_some(path),
log: log_path().ok(),
restarts: true,
..Status::absent(MECHANISM)
}
}
pub fn install(exe: &Path, port: Option<u16>) -> Result<Status> {
let path = plist_path()?;
let dir = path
.parent()
.expect("a plist has a directory")
.to_path_buf();
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let log = log_path()?;
let mut args = vec![exe.display().to_string(), "start".to_string()];
if let Some(port) = port {
args.push("--port".to_string());
args.push(port.to_string());
}
let program: String = args
.iter()
.map(|a| format!(" <string>{}</string>\n", escape(a)))
.collect();
let plist = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
<key>Label</key>\n <string>{label}</string>\n\
<key>ProgramArguments</key>\n <array>\n{program} </array>\n\
<key>RunAtLoad</key>\n <true/>\n\
<key>KeepAlive</key>\n <true/>\n\
<key>ProcessType</key>\n <string>{process_type}</string>\n\
<key>EnvironmentVariables</key>\n <dict>\n \
<key>{SERVICE_ENV}</key>\n <string>1</string>\n </dict>\n\
<key>StandardOutPath</key>\n <string>{log}</string>\n\
<key>StandardErrorPath</key>\n <string>{log}</string>\n\
</dict>\n\
</plist>\n",
label = LABEL,
process_type = PROCESS_TYPE,
log = escape(&log.display().to_string()),
);
std::fs::write(&path, plist).with_context(|| format!("writing {}", path.display()))?;
let domain = format!("gui/{}", unsafe { libc::getuid() });
let _ = run("launchctl", &["bootout", &format!("{domain}/{LABEL}")]);
if run(
"launchctl",
&["bootstrap", &domain, &path.display().to_string()],
)
.is_err()
{
run("launchctl", &["load", "-w", &path.display().to_string()])
.context("loading the launchd agent")?;
}
Ok(status())
}
pub fn remove() -> Result<bool> {
let path = plist_path()?;
let domain = format!("gui/{}", unsafe { libc::getuid() });
let _ = run("launchctl", &["bootout", &format!("{domain}/{LABEL}")]);
let _ = run("launchctl", &["unload", "-w", &path.display().to_string()]);
if !path.exists() {
return Ok(false);
}
std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
Ok(true)
}
fn escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
}
#[cfg(target_os = "linux")]
mod platform {
use super::*;
pub const MECHANISM: &str = "systemd";
const UNIT: &str = "semlith.service";
pub fn stale_definition() -> Option<String> {
let path = unit_path().ok()?;
let text = std::fs::read_to_string(&path).ok()?;
if !text.contains("CPUWeight=") {
return Some(format!(
"{} predates the daemon's lower scheduling weight (Nice, CPUWeight, IOWeight); \
run `semlith setup` (or `semlith start --service`) to rewrite it",
path.display()
));
}
let starts = text
.lines()
.any(|l| l.starts_with("ExecStart=") && l.contains(" start"));
(!starts).then(|| {
format!(
"{} runs semlith without `start`, so the service never serves; run `semlith \
setup` (or `semlith start --service`) to rewrite it",
path.display()
)
})
}
fn unit_path() -> Result<PathBuf> {
Ok(crate::home::user_home()?
.join(".config")
.join("systemd")
.join("user")
.join(UNIT))
}
pub fn status() -> Status {
let Ok(path) = unit_path() else {
return Status::absent(MECHANISM);
};
let enabled = run("systemctl", &["--user", "is-enabled", UNIT])
.map(|out| out.trim() == "enabled")
.unwrap_or(false);
Status {
installed: path.is_file() && enabled,
definition: path.is_file().then_some(path),
log: log_path().ok(),
restarts: true,
..Status::absent(MECHANISM)
}
}
pub fn install(exe: &Path, port: Option<u16>) -> Result<Status> {
let path = unit_path()?;
let dir = path.parent().expect("a unit has a directory").to_path_buf();
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let port = port.map(|p| format!(" --port {p}")).unwrap_or_default();
let unit = format!(
"[Unit]\n\
Description=semlith — local semantic index for agent clients\n\
Documentation=https://github.com/semlith/semlith\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={exe} start{port}\n\
Restart=always\n\
Environment={SERVICE_ENV}=1\n\
RestartSec=2\n\
Nice=5\n\
CPUWeight=50\n\
IOWeight=50\n\
\n\
[Install]\n\
WantedBy=default.target\n",
exe = systemd_quote(&exe.display().to_string()),
);
std::fs::write(&path, unit).with_context(|| format!("writing {}", path.display()))?;
run("systemctl", &["--user", "daemon-reload"]).context("reloading systemd --user")?;
run("systemctl", &["--user", "enable", "--now", UNIT])
.context("enabling the systemd user unit")?;
Ok(status())
}
pub fn remove() -> Result<bool> {
let path = unit_path()?;
let _ = run("systemctl", &["--user", "disable", "--now", UNIT]);
let existed = path.exists();
if existed {
std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
}
let _ = run("systemctl", &["--user", "daemon-reload"]);
Ok(existed)
}
}
#[cfg(windows)]
mod platform {
use super::*;
pub const MECHANISM: &str = "schtasks";
const TASK: &str = "semlith";
pub fn stale_definition() -> Option<String> {
let priority = powershell(&format!(
"(Get-ScheduledTask -TaskName '{TASK}' -ErrorAction Stop).Settings.Priority"
))
.ok()?;
let priority = priority.trim();
(priority != "5").then(|| {
format!(
"the {TASK} logon task runs at priority {priority} (below normal); run \
`semlith setup` (or `semlith start --service`) to register it at 5"
)
})
}
pub fn status() -> Status {
let installed = run("schtasks", &["/Query", "/TN", TASK]).is_ok();
Status {
installed,
log: log_path().ok(),
..Status::absent(MECHANISM)
}
}
pub fn install(exe: &Path, port: Option<u16>) -> Result<Status> {
let port = port.map(|p| format!(" --port {p}")).unwrap_or_default();
let script = format!(
"$ErrorActionPreference='Stop'; \
$action = New-ScheduledTaskAction -Execute {exe} -Argument 'start{port}'; \
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME; \
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries \
-DontStopIfGoingOnBatteries -Priority 5 -RestartCount 3 \
-RestartInterval (New-TimeSpan -Minutes 1) \
-ExecutionTimeLimit (New-TimeSpan -Seconds 0); \
Register-ScheduledTask -TaskName '{task}' -Action $action -Trigger $trigger \
-Settings $settings -Force | Out-Null; \
Start-ScheduledTask -TaskName '{task}'",
exe = crate::setup::powershell_quote(&exe.display().to_string()),
task = TASK,
);
powershell(&script).context("registering the logon task")?;
Ok(status())
}
pub fn remove() -> Result<bool> {
if run("schtasks", &["/Query", "/TN", TASK]).is_err() {
return Ok(false);
}
run("schtasks", &["/Delete", "/TN", TASK, "/F"]).context("deleting the logon task")?;
Ok(true)
}
fn powershell(script: &str) -> Result<String> {
run(
"powershell",
&["-NoProfile", "-NonInteractive", "-Command", script],
)
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
mod platform {
use super::*;
pub const MECHANISM: &str = "none";
pub fn stale_definition() -> Option<String> {
None
}
pub fn status() -> Status {
Status::absent(MECHANISM)
}
pub fn install(_exe: &Path, _port: Option<u16>) -> Result<Status> {
anyhow::bail!(
"semlith has no login service for this platform — \
run `semlith start` from whatever this system uses to start a user service"
)
}
pub fn remove() -> Result<bool> {
Ok(false)
}
}
fn run(program: &str, args: &[&str]) -> Result<String> {
let out = std::process::Command::new(program)
.args(args)
.output()
.with_context(|| format!("running {program}"))?;
if !out.status.success() {
let said = String::from_utf8_lossy(&out.stderr);
let said = if said.trim().is_empty() {
String::from_utf8_lossy(&out.stdout).trim().to_string()
} else {
said.trim().to_string()
};
let said = if said.is_empty() {
out.status.to_string()
} else {
said
};
anyhow::bail!("{program} {}: {said}", args.join(" "));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
pub fn stale_definition() -> Option<String> {
if seam().is_some() {
return None;
}
platform::stale_definition()
}
pub const SERVICE_DIR_ENV: &str = "SEMLITH_SERVICE_DIR";
fn seam() -> Option<PathBuf> {
#[cfg(any(debug_assertions, feature = "dev-overrides"))]
{
std::env::var_os(SERVICE_DIR_ENV)
.filter(|v| !v.is_empty())
.map(|dir| PathBuf::from(dir).join(format!("{LABEL}.service")))
}
#[cfg(not(any(debug_assertions, feature = "dev-overrides")))]
None
}
fn seam_status(file: PathBuf) -> Status {
Status {
installed: file.is_file(),
definition: file.is_file().then_some(file),
log: log_path().ok(),
restarts: true,
..Status::absent(platform::MECHANISM)
}
}
pub fn status() -> Status {
if let Some(file) = seam() {
return seam_status(file);
}
platform::status()
}
pub fn install(binary: Option<&Path>, port: Option<u16>) -> Result<Status> {
let exe = match binary {
Some(path) => plain(path.canonicalize().unwrap_or_else(|_| path.to_path_buf())),
None => exe()?,
};
if let Some(file) = seam() {
if let Some(dir) = file.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
let args = match port {
Some(port) => format!("{} start --port {port}\n", exe.display()),
None => format!("{} start\n", exe.display()),
};
std::fs::write(&file, args).with_context(|| format!("writing {}", file.display()))?;
return Ok(seam_status(file));
}
if under_temp(&exe) {
anyhow::bail!(
"{} is under the temporary directory, and a login service pointing there stops \
working when it is cleaned up. Install semlith first (`semlith setup`) and \
register the installed binary.",
exe.display()
);
}
if let Some(guarded) = tcc_guarded(&exe) {
anyhow::bail!(
"this binary is at {} — macOS gates {} for a background login agent, and a launchd job pointing there hangs inside the dynamic linker before it can write a word to its own log. Install semlith outside that directory and run `semlith start --service` from there; `semlith setup` puts it in {}.",
exe.display(),
guarded.display(),
crate::home::bin_dir()
.map(|d| d.display().to_string())
.unwrap_or_else(|_| "the semlith home's bin directory".to_string()),
);
}
let answering = crate::daemon::port_of(port);
if already_answering(answering) {
let mut status = platform::install(&exe, port)?;
status.started_now = false;
return Ok(status);
}
let mut status = platform::install(&exe, port)?;
status.started_now = status.installed && waits_for(answering);
Ok(status)
}
pub fn restart_commands(mechanism: &str, uid: u32) -> Vec<Vec<String>> {
let owned = |args: &[&str]| args.iter().map(|a| a.to_string()).collect::<Vec<_>>();
match mechanism {
"launchd" => vec![owned(&[
"launchctl",
"kickstart",
"-k",
&format!("gui/{uid}/{LABEL}"),
])],
"systemd" => vec![owned(&[
"systemctl",
"--user",
"restart",
"semlith.service",
])],
"schtasks" => vec![
owned(&["schtasks", "/End", "/TN", "semlith"]),
owned(&["schtasks", "/Run", "/TN", "semlith"]),
],
_ => Vec::new(),
}
}
pub fn is_stale(running: Option<&str>) -> bool {
running.is_some_and(|v| v != env!("CARGO_PKG_VERSION"))
}
pub fn restart_if_stale() -> Result<Option<String>> {
let running = running_version();
if !is_stale(running.as_deref()) {
return Ok(None);
}
let previous = running.unwrap_or_default();
if answers_current_within(std::time::Duration::from_secs(5)) {
return Ok(Some(previous));
}
let status = status();
if !status.installed {
anyhow::bail!(
"a semlith {previous} daemon is running, not started by the login service; \
stop it and run `semlith start` so clients reach {}",
env!("CARGO_PKG_VERSION")
);
}
#[cfg(unix)]
let uid = unsafe { libc::getuid() };
#[cfg(not(unix))]
let uid = 0;
for command in restart_commands(status.mechanism, uid) {
let args: Vec<&str> = command[1..].iter().map(String::as_str).collect();
let result = run(&command[0], &args);
if command.get(1).map(String::as_str) != Some("/End")
&& let Err(e) = result
{
if answers_current_within(std::time::Duration::from_secs(10)) {
return Ok(Some(previous));
}
return Err(e).with_context(|| format!("restarting the {} service", status.mechanism));
}
}
if answers_current_within(std::time::Duration::from_secs(10)) {
return Ok(Some(previous));
}
anyhow::bail!(
"restarted the {} service, and no daemon answered at {} within 10 s; see {}",
status.mechanism,
env!("CARGO_PKG_VERSION"),
status
.log
.map(|p| p.display().to_string())
.unwrap_or_else(|| "the service log".into())
)
}
fn answers_current_within(wait: std::time::Duration) -> bool {
let deadline = std::time::Instant::now() + wait;
loop {
if running_version().as_deref() == Some(env!("CARGO_PKG_VERSION")) {
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
fn under_temp(exe: &Path) -> bool {
let temp = std::env::temp_dir();
let temp = temp.canonicalize().unwrap_or(temp);
let exe = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
exe.starts_with(&temp)
|| exe.starts_with("/private/var/folders")
|| exe.starts_with("/var/folders")
}
fn waits_for(port: u16) -> bool {
for _ in 0..20 {
if already_answering(port) {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
false
}
fn already_answering(port: u16) -> bool {
std::net::TcpStream::connect_timeout(
&std::net::SocketAddr::from(([127, 0, 0, 1], port)),
std::time::Duration::from_millis(300),
)
.is_ok()
}
#[allow(unused_variables)]
fn tcc_guarded(exe: &Path) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
let home = crate::home::user_home().ok()?;
[
"Documents",
"Desktop",
"Downloads",
"Library/Mobile Documents",
]
.iter()
.map(|name| home.join(name))
.find(|dir| exe.starts_with(dir))
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
pub fn remove() -> Result<bool> {
if let Some(file) = seam() {
if !file.exists() {
return Ok(false);
}
std::fs::remove_file(&file).with_context(|| format!("removing {}", file.display()))?;
return Ok(true);
}
platform::remove()
}
pub fn last_started() -> Option<i64> {
let registry = crate::home::Registry::load().ok()?;
registry
.stores
.keys()
.filter_map(|name| crate::home::Registry::dir_of(name).ok())
.filter_map(|dir| started_from(&crate::daemon::discovery_path(&dir)))
.max()
}
fn started_from(path: &Path) -> Option<i64> {
let text = std::fs::read_to_string(path).ok()?;
let found: crate::daemon::Discovery = serde_json::from_str(&text).ok()?;
if found.started > 0 {
return Some(found.started as i64);
}
let at = std::fs::metadata(path).ok()?.modified().ok()?;
Some(at.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs() as i64)
}
pub fn running_version() -> Option<String> {
let registry = crate::home::Registry::load().ok()?;
registry
.stores
.keys()
.filter_map(|name| crate::home::Registry::dir_of(name).ok())
.find_map(|dir| crate::daemon::Discovery::read(&dir))
.map(|found| found.version)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_service_path_is_quoted_for_systemd_and_powershell() {
assert_eq!(
systemd_quote(r#"/home/a b/100%/$x"q"/semlith"#),
r#""/home/a b/100%%/$$x\"q\"/semlith""#
);
assert_eq!(
crate::setup::powershell_quote("C:/Users/O\u{2019}Brien/it's/semlith.exe"),
"'C:/Users/O\u{2019}\u{2019}Brien/it''s/semlith.exe'"
);
}
#[test]
fn a_restart_is_the_command_each_mechanism_restarts_with() {
assert_eq!(
restart_commands("launchd", 501),
[["launchctl", "kickstart", "-k", "gui/501/com.semlith.daemon"]]
);
assert_eq!(
restart_commands("systemd", 1000),
[["systemctl", "--user", "restart", "semlith.service"]]
);
assert_eq!(
restart_commands("schtasks", 0),
[
["schtasks", "/End", "/TN", "semlith"],
["schtasks", "/Run", "/TN", "semlith"]
]
);
assert!(restart_commands("none", 0).is_empty());
}
#[test]
fn a_daemon_at_this_version_is_never_restarted() {
assert!(!is_stale(None));
assert!(!is_stale(Some(env!("CARGO_PKG_VERSION"))));
assert!(is_stale(Some("0.0.1")));
}
#[test]
fn a_verbatim_path_is_written_in_its_plain_spelling() {
assert_eq!(
plain(PathBuf::from(r"\\?\C:\Users\me\semlith.exe")),
PathBuf::from(r"C:\Users\me\semlith.exe"),
);
assert_eq!(
plain(PathBuf::from(r"\\?\UNC\server\share\semlith.exe")),
PathBuf::from(r"\\server\share\semlith.exe"),
);
assert_eq!(
plain(PathBuf::from("/usr/local/bin/semlith")),
PathBuf::from("/usr/local/bin/semlith"),
);
}
#[test]
#[cfg(target_os = "macos")]
fn a_binary_in_a_privacy_gated_directory_is_refused_rather_than_hung() {
let home = crate::home::user_home().expect("a home");
assert_eq!(
tcc_guarded(&home.join("Documents/code/semlith/target/release/semlith")),
Some(home.join("Documents")),
);
assert_eq!(
tcc_guarded(&home.join("Desktop/semlith")),
Some(home.join("Desktop")),
);
assert_eq!(tcc_guarded(&home.join(".semlith/bin/semlith")), None);
assert_eq!(tcc_guarded(Path::new("/usr/local/bin/semlith")), None);
}
#[test]
fn a_port_something_already_holds_is_seen_as_held() {
let held = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("a port");
let port = held.local_addr().expect("an address").port();
assert!(
already_answering(port),
"a bound port was not seen as bound"
);
drop(held);
let free = (0..20).any(|_| {
let probe = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("a port");
let port = probe.local_addr().expect("an address").port();
drop(probe);
!already_answering(port)
});
assert!(
free,
"a freed port was still seen as bound, twenty times over"
);
}
#[test]
fn removing_a_service_that_is_not_installed_is_not_an_error() {
let _guard = crate::home::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let found = status();
if found.installed || found.definition.is_some() {
return;
}
assert!(matches!(remove(), Ok(false)), "removing nothing failed");
}
}