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",
"gemini" => "gemini",
"antigravity" => "antigravity",
_ => "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 unit_path(paths: &Paths, tool: &str) -> PathBuf {
if cfg!(target_os = "macos") {
launchd_path(paths.home(), tool)
} else {
systemd_path(paths.home(), tool)
}
}
pub fn manages_the_real_machine(paths: &Paths) -> bool {
dirs::home_dir().is_some_and(|h| h == paths.home())
}
pub fn unit_program(body: &str) -> Option<&str> {
if let Some(line) = body
.lines()
.find_map(|l| l.trim().strip_prefix("ExecStart="))
{
return line.split_whitespace().next();
}
let rest = body.split("ProgramArguments").nth(1)?;
let open = rest.find("<string>")? + "<string>".len();
let close = rest[open..].find("</string>")? + open;
Some(rest[open..close].trim())
}
pub fn restart_argv(tool: &str, macos: bool, uid: u32) -> Vec<String> {
if macos {
vec![
"launchctl".into(),
"kickstart".into(),
"-k".into(),
format!("gui/{uid}/{}", launchd_label(tool)),
]
} else {
vec![
"systemctl".into(),
"--user".into(),
"restart".into(),
systemd_unit(tool),
]
}
}
pub fn restart_via_supervisor(home: Option<&Path>, tool: &str) -> bool {
let macos = cfg!(target_os = "macos");
let installed = home.is_some_and(|h| {
if macos {
launchd_path(h, tool).exists()
} else {
systemd_path(h, tool).exists()
}
});
if !installed {
return false;
}
let argv = restart_argv(tool, macos, unsafe { libc::getuid() });
std::process::Command::new(&argv[0])
.args(&argv[1..])
.output()
.is_ok_and(|o| o.status.success())
}
pub fn parse_nrestarts(out: &str) -> Option<u32> {
out.trim().parse().ok()
}
pub fn parse_launchctl_last_exit(out: &str) -> Option<i32> {
out.lines()
.find_map(|l| l.split_once("\"LastExitStatus\" = "))
.and_then(|(_, rest)| rest.trim().trim_end_matches(';').parse().ok())
}
pub fn supervisor_report(tool: &str) -> (Option<u32>, Option<i32>, Option<u64>) {
if cfg!(target_os = "macos") {
let out = std::process::Command::new("launchctl")
.args(["list", &launchd_label(tool)])
.output()
.ok();
let text = out
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
(
None,
parse_launchctl_last_exit(&text),
launchd_uptime_secs(&text),
)
} else {
let out = std::process::Command::new("systemctl")
.args([
"--user",
"show",
&systemd_unit(tool),
"-p",
"NRestarts",
"--value",
])
.output()
.ok();
let text = out
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
(parse_nrestarts(&text), None, systemd_uptime_secs(tool))
}
}
pub fn parse_etime(out: &str) -> Option<u64> {
let t = out.trim();
if t.is_empty() {
return None;
}
let (days, rest) = match t.split_once('-') {
Some((d, r)) => (d.parse::<u64>().ok()?, r),
None => (0, t),
};
let mut parts = rest.split(':').rev();
let secs: u64 = parts.next()?.parse().ok()?;
let mins: u64 = parts.next()?.parse().ok()?;
let hours: u64 = match parts.next() {
Some(h) => h.parse().ok()?,
None => 0,
};
if parts.next().is_some() {
return None;
}
Some(days * 86400 + hours * 3600 + mins * 60 + secs)
}
pub fn launchd_uptime_secs(list_output: &str) -> Option<u64> {
let pid: i32 = list_output
.lines()
.find_map(|l| l.split_once("\"PID\" = "))
.and_then(|(_, r)| r.trim().trim_end_matches(';').parse().ok())?;
let out = std::process::Command::new("ps")
.args(["-o", "etime=", "-p", &pid.to_string()])
.output()
.ok()?;
parse_etime(&String::from_utf8_lossy(&out.stdout))
}
pub fn systemd_uptime_secs(tool: &str) -> Option<u64> {
let out = std::process::Command::new("systemctl")
.args([
"--user",
"show",
&systemd_unit(tool),
"-p",
"ActiveEnterTimestampMonotonic",
"--value",
])
.output()
.ok()?;
let entered: u64 = String::from_utf8_lossy(&out.stdout).trim().parse().ok()?;
if entered == 0 {
return None;
}
let mut ts = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) } != 0 {
return None;
}
let now_us = ts.tv_sec as u64 * 1_000_000 + ts.tv_nsec as u64 / 1_000;
now_us.checked_sub(entered).map(|d| d / 1_000_000)
}
pub fn supervision_note(
restarts: Option<u32>,
last_exit: Option<i32>,
uptime_secs: Option<u64>,
) -> Option<String> {
let settled = uptime_secs.is_some_and(|u| u >= 3600);
let since = uptime_secs.map(for_how_long).unwrap_or_default();
if let Some(n) = restarts {
if n > 0 {
let how = if n == 1 {
"once".to_string()
} else {
format!("{n} times")
};
return Some(if settled {
format!("restarted {how}, but up {since} since")
} else {
format!("restarted {how} - it is not staying up")
});
}
}
match last_exit {
Some(0) | None => None,
Some(c) if settled => Some(format!("a previous run exited {c}; up {since} since")),
Some(c) => Some(format!("last exit {c}")),
}
}
fn for_how_long(secs: u64) -> String {
let days = secs / 86400;
let hours = (secs % 86400) / 3600;
let mins = (secs % 3600) / 60;
if days > 0 {
format!("{days}d {hours}h")
} else if hours > 0 {
format!("{hours}h {mins}m")
} else {
format!("{mins}m")
}
}
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 logs = log_dir(paths);
std::fs::create_dir_all(&logs).ok();
let path = unit_path(paths, tool);
let (path, body) = if cfg!(target_os = "macos") {
(path, launchd_plist(&exe, tool, &logs))
} else {
(path, 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);
if manages_the_real_machine(paths) {
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(paths: &Paths, tool: &str) -> anyhow::Result<Option<PathBuf>> {
use anyhow::Context;
let path = unit_path(paths, tool);
if !path.exists() {
return Ok(None);
}
if manages_the_real_machine(paths) {
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")
);
}
}
#[cfg(test)]
mod unit_program_tests {
use super::*;
#[test]
fn the_program_a_unit_will_run_can_be_read_back() {
let systemd = "[Unit]\nDescription=x\n[Service]\n\
ExecStart=/opt/swapdex/bin/swapdex proxy --tool claude-code --port 8787\n\
Restart=always\n";
assert_eq!(unit_program(systemd), Some("/opt/swapdex/bin/swapdex"));
let plist = "<plist><dict>\n<key>ProgramArguments</key>\n<array>\n\
<string>/usr/local/bin/swapdex</string>\n<string>proxy</string>\n\
</array>\n</dict></plist>\n";
assert_eq!(unit_program(plist), Some("/usr/local/bin/swapdex"));
assert_eq!(unit_program("nothing here"), None);
}
}
#[cfg(test)]
mod supervision_note_tests {
use super::*;
#[test]
fn a_proxy_that_stays_up_gets_no_note() {
assert_eq!(supervision_note(Some(0), Some(0), None), None);
assert_eq!(supervision_note(None, None, None), None);
}
#[test]
fn history_is_not_reported_as_a_present_problem() {
let day = 25 * 3600;
let old_exit = supervision_note(None, Some(9), Some(day)).expect("still worth mentioning");
assert!(
old_exit.contains("previous") || old_exit.contains("since"),
"must place it in the past: {old_exit}"
);
assert!(!old_exit.contains("not staying up"), "not now: {old_exit}");
let old_restarts = supervision_note(Some(5), None, Some(day)).expect("worth mentioning");
assert!(
!old_restarts.contains("not staying up"),
"it plainly is staying up: {old_restarts}"
);
assert!(
old_restarts.contains('5'),
"the count still shows: {old_restarts}"
);
}
#[test]
fn a_short_uptime_with_restarts_is_still_an_alarm() {
let note = supervision_note(Some(5), None, Some(90)).expect("this one is real");
assert!(note.contains("not staying up"), "should alarm: {note}");
}
#[test]
fn systemd_restarts_are_reported() {
let note = supervision_note(Some(5), Some(1), None).expect("five restarts is worth saying");
assert!(note.contains('5'), "should name the count: {note}");
}
#[test]
fn one_restart_reads_as_english() {
let note =
supervision_note(Some(1), None, None).expect("one restart is still worth saying");
assert!(note.contains("once"), "not 'restarted 1 times': {note}");
}
#[test]
fn launchd_reports_the_last_exit_instead() {
assert_eq!(supervision_note(None, Some(0), None), None);
let note =
supervision_note(None, Some(137), None).expect("a non-zero exit is worth saying");
assert!(note.contains("137"), "should name the status: {note}");
}
}
#[cfg(test)]
mod supervisor_parse_tests {
use super::*;
#[test]
fn systemd_restart_count() {
assert_eq!(parse_nrestarts("5\n"), Some(5));
assert_eq!(parse_nrestarts("0\n"), Some(0));
assert_eq!(parse_nrestarts("\n"), None);
assert_eq!(parse_nrestarts("[not-set]"), None);
}
#[test]
fn launchctl_last_exit_status() {
let block = "{\n\t\"Label\" = \"io.github.youdie006.swapdex.claude\";\n\
\t\"LastExitStatus\" = 137;\n\t\"PID\" = 4242;\n}";
assert_eq!(parse_launchctl_last_exit(block), Some(137));
let ok = "{\n\t\"LastExitStatus\" = 0;\n}";
assert_eq!(parse_launchctl_last_exit(ok), Some(0));
assert_eq!(
parse_launchctl_last_exit("Could not find service in domain"),
None
);
}
}
#[cfg(test)]
mod restart_argv_tests {
use super::*;
#[test]
fn systemd_is_asked_to_restart_its_own_unit() {
let argv = restart_argv("claude-code", false, 1000);
assert_eq!(
argv,
vec![
"systemctl".to_string(),
"--user".into(),
"restart".into(),
"swapdex-claude.service".into()
]
);
}
#[test]
fn launchd_kickstarts_the_agent_in_the_user_domain() {
let argv = restart_argv("codex", true, 501);
assert_eq!(
argv,
vec![
"launchctl".to_string(),
"kickstart".into(),
"-k".into(),
"gui/501/io.github.youdie006.swapdex.codex".into()
]
);
}
}
#[cfg(test)]
mod supervisor_sandbox_tests {
use super::*;
#[test]
fn an_empty_home_has_no_supervisor_to_ask() {
let home = tempfile::tempdir().unwrap();
assert!(!restart_via_supervisor(Some(home.path()), "claude-code"));
assert!(!restart_via_supervisor(Some(home.path()), "codex"));
}
#[test]
fn no_home_at_all_is_not_a_supervisor_either() {
assert!(!restart_via_supervisor(None, "claude-code"));
}
}
#[cfg(test)]
mod etime_tests {
use super::*;
#[test]
fn etime_is_parsed_in_every_shape_ps_prints() {
assert_eq!(parse_etime("01-01:06:48"), Some(90408)); assert_eq!(parse_etime("01-05:27:51"), Some(106071)); assert_eq!(parse_etime(" 01:02:03"), Some(3723)); assert_eq!(parse_etime(" 12:34"), Some(754)); assert_eq!(parse_etime("00:00"), Some(0));
assert_eq!(parse_etime(""), None);
assert_eq!(parse_etime("not a duration"), None);
}
}
#[cfg(test)]
mod sandbox_containment_tests {
use super::*;
#[test]
fn the_unit_lands_under_the_paths_home_not_the_ambient_one() {
let root = tempfile::tempdir().unwrap();
let paths = crate::paths::Paths::rooted(root.path());
for tool in ["claude-code", "codex"] {
let p = unit_path(&paths, tool);
assert!(
p.starts_with(root.path()),
"{tool} unit escaped the sandbox: {}",
p.display()
);
}
}
#[test]
fn a_sandboxed_run_does_not_drive_the_supervisor() {
let root = tempfile::tempdir().unwrap();
assert!(!manages_the_real_machine(&crate::paths::Paths::rooted(
root.path()
)));
}
}
#[cfg(test)]
mod tool_identity_tests {
use super::*;
#[test]
fn every_tool_gets_its_own_unit_and_label() {
let tools = ["claude-code", "codex", "gemini", "antigravity"];
let units: Vec<String> = tools.iter().map(|t| systemd_unit(t)).collect();
let labels: Vec<String> = tools.iter().map(|t| launchd_label(t)).collect();
for i in 0..tools.len() {
for j in (i + 1)..tools.len() {
assert_ne!(
units[i], units[j],
"{} and {} share a unit file",
tools[i], tools[j]
);
assert_ne!(
labels[i], labels[j],
"{} and {} share a launchd label",
tools[i], tools[j]
);
}
}
}
#[test]
fn the_two_that_already_exist_keep_their_names() {
assert_eq!(systemd_unit("claude-code"), "swapdex-claude.service");
assert_eq!(systemd_unit("codex"), "swapdex-codex.service");
}
#[test]
fn every_tool_gets_its_own_port() {
let tools = ["claude-code", "codex", "gemini", "antigravity"];
let ports: Vec<u16> = tools
.iter()
.map(|t| crate::commands::default_port_for(t))
.collect();
for i in 0..tools.len() {
for j in (i + 1)..tools.len() {
assert_ne!(
ports[i], ports[j],
"{} and {} both want port {}",
tools[i], tools[j], ports[i]
);
}
}
}
}