use crate::paths::Paths;
use std::path::{Path, PathBuf};
pub fn launchd_label(tool: &str) -> String {
format!("io.github.youdie006.swapdex.{}", short_tool(tool))
}
pub fn systemd_unit(tool: &str) -> String {
format!("swapdex-{}.service", short_tool(tool))
}
fn short_tool(tool: &str) -> &'static str {
match tool {
"codex" => "codex",
_ => "claude",
}
}
pub fn launchd_plist(exe: &Path, tool: &str, log_dir: &Path) -> String {
let label = launchd_label(tool);
let port = crate::commands::default_port_for(tool);
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>
<string>proxy</string>
<string>--tool</string>
<string>{tool}</string>
<string>--port</string>
<string>{port}</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ProcessType</key><string>Background</string>
<key>StandardOutPath</key><string>{log}/{label}.log</string>
<key>StandardErrorPath</key><string>{log}/{label}.log</string>
</dict>
</plist>
"#,
exe = exe.display(),
log = log_dir.display(),
)
}
pub fn systemd_service(exe: &Path, tool: &str) -> String {
let port = crate::commands::default_port_for(tool);
format!(
"[Unit]\n\
Description=swapdex {tool} proxy\n\
After=network-online.target\n\
\n\
[Service]\n\
ExecStart={exe} proxy --tool {tool} --port {port}\n\
Restart=always\n\
RestartSec=5\n\
\n\
[Install]\n\
WantedBy=default.target\n",
exe = exe.display(),
)
}
pub fn launchd_path(home: &Path, tool: &str) -> PathBuf {
home.join("Library/LaunchAgents")
.join(format!("{}.plist", launchd_label(tool)))
}
pub fn systemd_path(home: &Path, tool: &str) -> PathBuf {
home.join(".config/systemd/user").join(systemd_unit(tool))
}
pub fn log_dir(paths: &Paths) -> PathBuf {
paths.store_dir().join("logs")
}
pub fn install(paths: &Paths, tool: &str) -> anyhow::Result<PathBuf> {
use anyhow::Context;
let exe = std::env::current_exe().context("cannot find swapdex's own path")?;
let exe = std::fs::canonicalize(&exe).unwrap_or(exe);
let home = dirs::home_dir().context("cannot determine home dir")?;
let logs = log_dir(paths);
std::fs::create_dir_all(&logs).ok();
let (path, body) = if cfg!(target_os = "macos") {
(launchd_path(&home, tool), launchd_plist(&exe, tool, &logs))
} else {
(systemd_path(&home, tool), systemd_service(&exe, tool))
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).context("create the service directory")?;
}
std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
stop_running(paths, tool);
load(&path, tool);
Ok(path)
}
fn stop_running(paths: &Paths, tool: &str) {
let Some((pid, _, _)) = crate::proxy::running_proxy_for(paths, tool) else {
return;
};
unsafe { libc::kill(pid, libc::SIGTERM) };
for _ in 0..40 {
if crate::proxy::running_proxy_for(paths, tool).is_none() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
fn load(path: &Path, tool: &str) {
let run = |prog: &str, args: &[&str], quiet: bool| {
let out = std::process::Command::new(prog).args(args).output();
match out {
Ok(o) if o.status.success() => true,
Ok(o) => {
let e = String::from_utf8_lossy(&o.stderr);
let e = e.trim();
if !e.is_empty() && !quiet {
eprintln!("swapdex: {prog} said: {e}");
}
false
}
Err(e) => {
eprintln!("swapdex: could not run {prog}: {e}");
false
}
}
};
if cfg!(target_os = "macos") {
let p = path.display().to_string();
let target = format!("gui/{}", unsafe { libc::getuid() });
run("launchctl", &["bootout", &target, &p], true);
run("launchctl", &["bootstrap", &target, &p], false);
} else {
run("systemctl", &["--user", "daemon-reload"], false);
run(
"systemctl",
&["--user", "enable", "--now", &systemd_unit(tool)],
false,
);
}
}
pub fn uninstall(tool: &str) -> anyhow::Result<Option<PathBuf>> {
use anyhow::Context;
let home = dirs::home_dir().context("cannot determine home dir")?;
let path = if cfg!(target_os = "macos") {
launchd_path(&home, tool)
} else {
systemd_path(&home, tool)
};
if !path.exists() {
return Ok(None);
}
if cfg!(target_os = "macos") {
let target = format!("gui/{}", unsafe { libc::getuid() });
let _ = std::process::Command::new("launchctl")
.args(["bootout", &target, &path.display().to_string()])
.output();
} else {
let _ = std::process::Command::new("systemctl")
.args(["--user", "disable", "--now", &systemd_unit(tool)])
.output();
}
std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
Ok(Some(path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_agent_restarts_the_proxy_and_keeps_its_output() {
let p = launchd_plist(
Path::new("/opt/homebrew/bin/swapdex"),
"claude-code",
Path::new("/tmp/logs"),
);
assert!(
p.contains("<key>KeepAlive</key><true/>"),
"it comes back: {p}"
);
assert!(
p.contains("<key>RunAtLoad</key><true/>"),
"and starts at login"
);
assert!(
p.contains("/opt/homebrew/bin/swapdex"),
"the real binary, not a name off PATH"
);
assert!(
p.contains("--tool") && p.contains("claude-code"),
"one agent per tool: {p}"
);
assert!(
p.contains("/tmp/logs/io.github.youdie006.swapdex.claude.log"),
"its output is kept, because a proxy that logs nowhere cannot be diagnosed: {p}"
);
}
#[test]
fn each_agent_carries_the_port_it_should_bind() {
let claude = launchd_plist(Path::new("/x/swapdex"), "claude-code", Path::new("/l"));
let codex = launchd_plist(Path::new("/x/swapdex"), "codex", Path::new("/l"));
assert!(claude.contains("<string>8787</string>"), "{claude}");
assert!(codex.contains("<string>8788</string>"), "{codex}");
let u = systemd_service(Path::new("/x/swapdex"), "codex");
assert!(u.contains("--port 8788"), "{u}");
}
#[test]
fn the_two_tools_get_separate_agents() {
assert_ne!(launchd_label("claude-code"), launchd_label("codex"));
assert_ne!(systemd_unit("claude-code"), systemd_unit("codex"));
}
#[test]
fn systemd_runs_as_the_user_and_restarts() {
let u = systemd_service(Path::new("/usr/local/bin/swapdex"), "codex");
assert!(u.contains("Restart=always"), "{u}");
assert!(u.contains("WantedBy=default.target"), "a user unit: {u}");
assert!(!u.contains("multi-user.target"), "never a system unit: {u}");
assert!(!u.contains("User="), "it runs as whoever enabled it: {u}");
}
#[test]
fn the_files_land_where_the_supervisor_looks() {
let home = Path::new("/Users/x");
assert_eq!(
launchd_path(home, "claude-code"),
home.join("Library/LaunchAgents/io.github.youdie006.swapdex.claude.plist")
);
assert_eq!(
systemd_path(home, "codex"),
home.join(".config/systemd/user/swapdex-codex.service")
);
}
}