use std::path::{Path, PathBuf};
use serde::Serialize;
pub const LABEL: &str = "com.loopflow.lfd";
#[derive(Debug, Clone)]
pub struct ServiceSpec {
pub lfd_path: PathBuf,
pub addr: String,
pub repo_root: PathBuf,
pub lf_home: Option<PathBuf>,
pub db_path: Option<PathBuf>,
pub path_env: Option<String>,
pub doppler_project: Option<String>,
pub doppler_config: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ServiceFile {
pub path: PathBuf,
pub platform: &'static str,
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn shell_escape(value: &str) -> String {
if value.is_empty()
|| value
.chars()
.any(|c| c.is_whitespace() || matches!(c, '"' | '\'' | '$'))
{
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
} else {
value.to_string()
}
}
pub fn render_launchd_plist(spec: &ServiceSpec) -> String {
let program_args = [
spec.lfd_path.to_string_lossy().to_string(),
"serve".to_string(),
"--addr".to_string(),
spec.addr.clone(),
"--repo".to_string(),
spec.repo_root.to_string_lossy().to_string(),
];
let program_args_xml = program_args
.iter()
.map(|arg| format!(" <string>{}</string>", xml_escape(arg)))
.collect::<Vec<_>>()
.join("\n");
let mut env = String::new();
if let Some(home) = &spec.lf_home {
env.push_str(&format!(
" <key>LF_HOME</key>\n <string>{}</string>\n",
xml_escape(&home.to_string_lossy())
));
}
if let Some(db) = &spec.db_path {
env.push_str(&format!(
" <key>LF_DB_PATH</key>\n <string>{}</string>\n",
xml_escape(&db.to_string_lossy())
));
}
if let Some(path) = &spec.path_env {
env.push_str(&format!(
" <key>PATH</key>\n <string>{}</string>\n",
xml_escape(path)
));
}
if let Some(project) = &spec.doppler_project {
env.push_str(&format!(
" <key>DOPPLER_PROJECT</key>\n <string>{}</string>\n",
xml_escape(project)
));
}
if let Some(config) = &spec.doppler_config {
env.push_str(&format!(
" <key>DOPPLER_CONFIG</key>\n <string>{}</string>\n",
xml_escape(config)
));
}
let env_block = if env.is_empty() {
String::new()
} else {
format!(" <key>EnvironmentVariables</key>\n <dict>\n{env} </dict>\n")
};
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
{program_args_xml}
</array>
{env_block}<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/dev/null</string>
<key>StandardErrorPath</key>
<string>/dev/null</string>
</dict>
</plist>
"#,
label = LABEL,
)
}
pub fn render_systemd_unit(spec: &ServiceSpec) -> String {
let mut env_lines = String::new();
if let Some(home) = &spec.lf_home {
env_lines.push_str(&format!(
"Environment=LF_HOME={}\n",
shell_escape(&home.to_string_lossy())
));
}
if let Some(db) = &spec.db_path {
env_lines.push_str(&format!(
"Environment=LF_DB_PATH={}\n",
shell_escape(&db.to_string_lossy())
));
}
if let Some(path) = &spec.path_env {
env_lines.push_str(&format!("Environment=PATH={}\n", shell_escape(path)));
}
if let Some(project) = &spec.doppler_project {
env_lines.push_str(&format!(
"Environment=DOPPLER_PROJECT={}\n",
shell_escape(project)
));
}
if let Some(config) = &spec.doppler_config {
env_lines.push_str(&format!(
"Environment=DOPPLER_CONFIG={}\n",
shell_escape(config)
));
}
let exec_start = shell_escape(&spec.lfd_path.to_string_lossy());
format!(
"[Unit]\nDescription=Loopflow Home daemon (lfd)\n\n\
[Service]\n\
Type=simple\n\
ExecStart={exec_start} serve --addr {addr} --repo {repo}\n\
Restart=on-failure\n\
RestartSec=5\n\
{env_lines}\
StandardOutput=null\n\
StandardError=null\n\n\
[Install]\n\
WantedBy=default.target\n",
addr = shell_escape(&spec.addr),
repo = shell_escape(&spec.repo_root.to_string_lossy()),
)
}
fn write_service_file(path: &Path, contents: &str) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
drop(file);
std::fs::write(path, contents)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub fn install(spec: &ServiceSpec) -> anyhow::Result<ServiceFile> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home directory to install into"))?;
let dir = home.join("Library/LaunchAgents");
let path = dir.join(format!("{LABEL}.plist"));
let plist = render_launchd_plist(spec);
write_service_file(&path, &plist)?;
let _ = std::process::Command::new("launchctl")
.arg("unload")
.arg(&path)
.status();
let status = std::process::Command::new("launchctl")
.arg("load")
.arg(&path)
.status()?;
if !status.success() {
anyhow::bail!("launchctl load failed for {}", path.display());
}
Ok(ServiceFile {
path,
platform: "launchd",
})
}
#[cfg(target_os = "linux")]
pub fn install(spec: &ServiceSpec) -> anyhow::Result<ServiceFile> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home directory to install into"))?;
let dir = home.join(".config/systemd/user");
let path = dir.join("lfd.service");
let unit = render_systemd_unit(spec);
write_service_file(&path, &unit)?;
let reload = std::process::Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status()?;
let enable = std::process::Command::new("systemctl")
.args(["--user", "enable", "--now", "lfd"])
.status()?;
if !reload.success() || !enable.success() {
anyhow::bail!("systemctl enable --now lfd failed");
}
Ok(ServiceFile {
path,
platform: "systemd",
})
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub fn install(_spec: &ServiceSpec) -> anyhow::Result<ServiceFile> {
anyhow::bail!("lfd install is supported on macOS and Linux only")
}
#[cfg(target_os = "macos")]
pub fn uninstall() -> anyhow::Result<PathBuf> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home directory to uninstall from"))?;
let path = home
.join("Library/LaunchAgents")
.join(format!("{LABEL}.plist"));
if path.exists() {
let _ = std::process::Command::new("launchctl")
.arg("unload")
.arg(&path)
.status();
std::fs::remove_file(&path)?;
}
Ok(path)
}
#[cfg(target_os = "linux")]
pub fn uninstall() -> anyhow::Result<PathBuf> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home directory to uninstall from"))?;
let path = home.join(".config/systemd/user/lfd.service");
if path.exists() {
let _ = std::process::Command::new("systemctl")
.args(["--user", "disable", "--now", "lfd"])
.status();
std::fs::remove_file(&path)?;
let _ = std::process::Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
}
Ok(path)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub fn uninstall() -> anyhow::Result<PathBuf> {
anyhow::bail!("lfd uninstall is supported on macOS and Linux only")
}
#[cfg(target_os = "macos")]
pub fn status() -> anyhow::Result<String> {
let out = std::process::Command::new("launchctl")
.args(["list", LABEL])
.output();
match out {
Ok(output) if output.status.success() => {
Ok(format!("lfd installed and loaded (launchd label {LABEL})"))
}
_ => Ok(format!("lfd not installed (no launchd label {LABEL})")),
}
}
#[cfg(target_os = "linux")]
pub fn status() -> anyhow::Result<String> {
let out = std::process::Command::new("systemctl")
.args(["--user", "is-active", "lfd"])
.output();
match out {
Ok(output) if output.status.success() => Ok("lfd active (systemd user unit)".to_string()),
Ok(output) => Ok(format!(
"lfd not active: {}",
String::from_utf8_lossy(&output.stdout).trim()
)),
_ => Ok("lfd not installed (no systemd user unit)".to_string()),
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub fn status() -> anyhow::Result<String> {
Ok("lfd service lifecycle is unsupported on this platform".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::SocketAddr;
fn spec() -> ServiceSpec {
ServiceSpec {
lfd_path: PathBuf::from("/usr/local/bin/lfd"),
addr: "127.0.0.1:8080".to_string(),
repo_root: PathBuf::from("/home/op/src/loopflow"),
lf_home: Some(PathBuf::from("/home/op/.lf")),
db_path: None,
path_env: Some("/opt/homebrew/bin:/usr/bin:/bin".to_string()),
doppler_project: Some("example-project".to_string()),
doppler_config: Some("example-config".to_string()),
}
}
#[test]
fn launchd_plist_carries_label_keepalive_and_non_secret_env_only() {
let plist = render_launchd_plist(&spec());
assert!(plist.contains("<string>com.loopflow.lfd</string>"));
assert!(plist.contains("<key>KeepAlive</key>"));
assert!(plist.contains("<key>RunAtLoad</key>"));
assert!(plist.contains("<integer>10</integer>"));
assert!(plist.contains("<key>LF_HOME</key>"));
assert!(plist.contains("/usr/local/bin/lfd</string>"));
assert!(plist.contains("serve</string>"));
assert!(plist.contains("127.0.0.1:8080</string>"));
assert!(plist.contains("--repo</string>"));
assert!(plist.contains("/home/op/src/loopflow</string>"));
assert!(plist.contains("<key>PATH</key>"));
assert!(plist.contains("/opt/homebrew/bin:/usr/bin:/bin"));
assert!(plist.contains("<key>DOPPLER_PROJECT</key>"));
assert!(plist.contains("<string>example-project</string>"));
assert!(plist.contains("<key>DOPPLER_CONFIG</key>"));
assert!(plist.contains("<string>example-config</string>"));
assert_eq!(plist.matches("<string>/dev/null</string>").count(), 2);
assert!(!plist.contains("WEBHOOK_SECRET"));
assert!(!plist.contains("VIEWER_ID"));
assert!(!plist.contains("AUTH_TOKEN"));
}
#[test]
fn systemd_unit_carries_execstart_restart_and_env_lines() {
let unit = render_systemd_unit(&spec());
assert!(unit.contains(
"ExecStart=/usr/local/bin/lfd serve --addr 127.0.0.1:8080 --repo /home/op/src/loopflow"
));
assert!(unit.contains("Restart=on-failure"));
assert!(unit.contains("RestartSec=5"));
assert!(unit.contains("StandardOutput=null"));
assert!(unit.contains("StandardError=null"));
assert!(unit.contains("Environment=LF_HOME=/home/op/.lf"));
assert!(unit.contains("Environment=PATH=/opt/homebrew/bin:/usr/bin:/bin"));
assert!(unit.contains("Environment=DOPPLER_PROJECT=example-project"));
assert!(unit.contains("Environment=DOPPLER_CONFIG=example-config"));
assert!(unit.contains("WantedBy=default.target"));
assert!(!unit.contains("WEBHOOK_SECRET"));
}
#[test]
fn service_files_omit_env_blocks_when_no_path_config_is_set() {
let bare = ServiceSpec {
lfd_path: PathBuf::from("/usr/local/bin/lfd"),
addr: "127.0.0.1:8080".to_string(),
repo_root: PathBuf::from("/home/op/src/loopflow"),
lf_home: None,
db_path: None,
path_env: None,
doppler_project: None,
doppler_config: None,
};
let plist = render_launchd_plist(&bare);
assert!(!plist.contains("EnvironmentVariables"));
let unit = render_systemd_unit(&bare);
assert!(!unit.contains("Environment="));
}
#[test]
fn xml_and_shell_escape_neutralize_metacharacters() {
let mut s = spec();
s.addr = "0.0.0.0:8080 \"injected\"".to_string();
let plist = render_launchd_plist(&s);
assert!(plist.contains(""injected""));
let unit = render_systemd_unit(&s);
assert!(unit.contains("0.0.0.0:8080 \\\"injected\\\""));
}
#[test]
fn install_refuses_without_a_home_directory() {
let path = std::env::temp_dir().join("lfd-service-render.plist");
write_service_file(&path, &render_launchd_plist(&spec())).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("com.loopflow.lfd"));
std::fs::remove_file(&path).ok();
let _: SocketAddr = "127.0.0.1:8080".parse().unwrap();
}
}