use std::path::{Path, PathBuf};
use std::process::Command;
use std::{env, fs, io};
pub const LABEL: &str = "dev.fmeier.tailcloak";
fn plist_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(home()?.join("Library/LaunchAgents").join(format!("{LABEL}.plist")))
}
fn log_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(home()?.join("Library/Logs/tailcloak.log"))
}
fn home() -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(PathBuf::from(env::var_os("HOME").ok_or("HOME is not set")?))
}
fn current_uid() -> Result<String, Box<dyn std::error::Error>> {
let out = Command::new("id").arg("-u").output()?;
if !out.status.success() {
return Err("`id -u` failed".into());
}
Ok(String::from_utf8(out.stdout)?.trim().to_owned())
}
fn plist_contents(exe: &Path, log: &Path) -> String {
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>
<string>{exe}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
</dict>
</plist>
"#,
exe = exe.display(),
log = log.display(),
)
}
pub fn install() -> Result<(), Box<dyn std::error::Error>> {
if crate::tailscale::resolve().is_none() {
eprintln!(
"tailcloak: WARNING — `tailscale` CLI not found in any standard location.\n\
The daemon cannot toggle Tailscale until it is installed \
(https://tailscale.com/download).\n\
Installing the agent anyway; it will start working once Tailscale is present."
);
}
let exe = env::current_exe()?.canonicalize()?;
let plist = plist_path()?;
let log = log_path()?;
if let Some(dir) = plist.parent() {
fs::create_dir_all(dir)?;
}
fs::write(&plist, plist_contents(&exe, &log))?;
let domain = format!("gui/{}", current_uid()?);
let target = format!("{domain}/{LABEL}");
let _ = Command::new("launchctl").args(["bootout", &target]).output();
let plist_str = plist.to_str().ok_or("plist path is not valid UTF-8")?;
let status = Command::new("launchctl")
.args(["bootstrap", &domain, plist_str])
.status()?;
if !status.success() {
return Err(format!("launchctl bootstrap failed ({status})").into());
}
println!("tailcloak: installed and started");
println!(" agent: {}", plist.display());
println!(" logs: {}", log.display());
Ok(())
}
pub fn uninstall() -> Result<(), Box<dyn std::error::Error>> {
let plist = plist_path()?;
let target = format!("gui/{}/{LABEL}", current_uid()?);
let _ = Command::new("launchctl").args(["bootout", &target]).output();
match fs::remove_file(&plist) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {} Err(e) => return Err(e.into()),
}
println!("tailcloak: uninstalled");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plist_embeds_binary_path_log_and_label() {
let xml = plist_contents(
Path::new("/Users/me/.cargo/bin/tailcloak"),
Path::new("/Users/me/Library/Logs/tailcloak.log"),
);
assert!(xml.contains("<string>/Users/me/.cargo/bin/tailcloak</string>"));
assert!(xml.contains("/Users/me/Library/Logs/tailcloak.log"));
assert!(xml.contains(&format!("<string>{LABEL}</string>")));
assert!(xml.contains("<key>RunAtLoad</key>\n <true/>"));
}
}