use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub const LABEL: &str = "com.semlith.daemon";
#[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);
Ok(dir.join("daemon.log"))
}
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";
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>Background</string>\n\
<key>StandardOutPath</key>\n <string>{log}</string>\n\
<key>StandardErrorPath</key>\n <string>{log}</string>\n\
</dict>\n\
</plist>\n",
label = LABEL,
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";
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}{port}\n\
Restart=always\n\
RestartSec=2\n\
\n\
[Install]\n\
WantedBy=default.target\n",
exe = exe.display(),
);
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 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 -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 = exe.display(),
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 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()
};
anyhow::bail!("{program} {}: {said}", args.join(" "));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
pub fn status() -> Status {
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(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)
}
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> {
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| std::fs::metadata(dir.join(crate::daemon::DISCOVERY_FILE)).ok())
.filter_map(|meta| meta.modified().ok())
.filter_map(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
.map(|since| since.as_secs() as i64)
.max()
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
assert!(
!already_answering(port),
"a freed port was still seen as bound"
);
}
#[test]
fn removing_a_service_that_is_not_installed_is_not_an_error() {
if status().installed {
return;
}
assert!(matches!(remove(), Ok(false)), "removing nothing failed");
}
}