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 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>) {
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))
} 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)
}
}
pub fn supervision_note(restarts: Option<u32>, last_exit: Option<i32>) -> Option<String> {
if let Some(n) = restarts {
if n > 0 {
let how = if n == 1 {
"once".to_string()
} else {
format!("{n} times")
};
return Some(format!("restarted {how} - it is not staying up"));
}
}
match last_exit {
Some(0) | None => None,
Some(c) => Some(format!("last exit {c}")),
}
}
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")
);
}
}
#[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);
assert_eq!(supervision_note(None, None), None);
}
#[test]
fn systemd_restarts_are_reported() {
let note = supervision_note(Some(5), Some(1)).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).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);
let note = supervision_note(None, Some(137)).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"));
}
}