From fdbc99d40ed4aa4f68669e1a5fdd59735b1e5196 Mon Sep 17 00:00:00 2001 From: Patrick Ecord Date: Sun, 21 Jun 2026 11:40:41 -0500 Subject: [PATCH] agent: install/uninstall as a login service (launchd / systemd) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add subcommands so the desktop agent runs in the background on login — the real desktop product shape, no window. `tether-agent install` writes a launchd LaunchAgent (macOS) or systemd user unit (Linux) with the resolved config baked in (run mode + server/account/name/room), loads it, and it survives reboots; `uninstall`/`status` manage it. Foreground `run` unchanged (the no-subcommand default). Windows falls through with a hint. Verified on macOS: install loads the agent (launchctl lists it, process runs with the baked args), uninstall removes it cleanly. Co-Authored-By: Claude Opus 4.8 --- agent/README.md | 20 +++++ agent/src/main.rs | 192 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 agent/README.md diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..ae50bde --- /dev/null +++ b/agent/README.md @@ -0,0 +1,20 @@ +# tether-agent + +Headless clipboard sync daemon on the shared `tethercore` engine (SSE + RTC + +LAN, prefer-direct E2E). One Rust binary for macOS / Linux / Windows. + +```sh +tether-agent # run in the foreground (Ctrl-C to stop) +tether-agent install # run on login as a background service +tether-agent uninstall # remove the service +tether-agent status # service status + +# config (applies to run + install; account derives the shared room): +tether-agent install --account you@example.com --name "Patrick's NAS" +``` + +- **macOS**: a launchd LaunchAgent at `~/Library/LaunchAgents/io.pecord.tether-agent.plist` + (logs → `~/Library/Logs/tether-agent.log`). +- **Linux**: a systemd user unit at `~/.config/systemd/user/tether-agent.service` + (`loginctl enable-linger` to start at boot without login). +- Defaults: `--server https://tether.pecord.io`, account-derived room, hostname as name. diff --git a/agent/src/main.rs b/agent/src/main.rs index c500559..e06a588 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -49,11 +49,19 @@ fn arg(flag: &str, default: &str) -> String { .unwrap_or_else(|| default.to_string()) } -fn main() { +struct AgentConfig { + server: String, + room: String, + source: String, + from: String, + name: String, + account: String, +} + +fn config() -> AgentConfig { let server = arg("--server", "https://tether.pecord.io"); let source = arg("--source", "agent"); let from = arg("--from", &format!("agent-{source}")); - // Friendly name for other devices' nearby lists. let host = std::env::var("HOSTNAME").unwrap_or_else(|_| "tether-agent".into()); let name = arg("--name", &host); // Shared identity: same-account devices pair on the LAN regardless of room, @@ -65,11 +73,25 @@ fn main() { tethercore::room_for_account(account.clone()) }; let room = arg("--room", &default_room); + AgentConfig { server, room, source, from, name, account } +} - eprintln!("tether-agent → {server} room={room} source={source}"); +fn main() { + match std::env::args().nth(1).as_deref() { + Some("install") => install(), + Some("uninstall") => uninstall(), + Some("status") => status(), + // No subcommand (or --flags) → run in the foreground. + _ => run(), + } +} + +fn run() { + let c = config(); + eprintln!("tether-agent → {} room={} source={}", c.server, c.room, c.source); let inbox = Arc::new(Mutex::new(Vec::::new())); - let engine = Engine::new(server, room, from, source, name, account); + let engine = Engine::new(c.server, c.room, c.from, c.source, c.name, c.account); engine.start(Box::new(Inbox(inbox.clone()))); let mut clip = Clipboard::new().expect("open system clipboard"); @@ -95,3 +117,165 @@ fn main() { sleep(Duration::from_millis(500)); } } + +// ── Service install (runs the agent on login as a background daemon) ───────── + +/// `run` + the resolved config, baked into the service so it's self-contained. +fn service_args() -> Vec { + let c = config(); + vec![ + "run".into(), + "--server".into(), c.server, + "--account".into(), c.account, + "--name".into(), c.name, + "--room".into(), c.room, + "--source".into(), c.source, + "--from".into(), c.from, + ] +} + +fn install() { + let exe = std::env::current_exe() + .expect("current exe") + .to_string_lossy() + .into_owned(); + let args = service_args(); + #[cfg(target_os = "macos")] + install_launchd(&exe, &args); + #[cfg(target_os = "linux")] + install_systemd(&exe, &args); + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (&exe, &args); + eprintln!("install isn't wired for this OS yet — run `tether-agent` directly."); + } +} + +fn uninstall() { + #[cfg(target_os = "macos")] + { + let plist = launchd_plist_path(); + let _ = std::process::Command::new("launchctl").args(["unload", &plist]).status(); + let _ = std::fs::remove_file(&plist); + println!("✅ uninstalled launchd agent."); + } + #[cfg(target_os = "linux")] + { + let _ = std::process::Command::new("systemctl") + .args(["--user", "disable", "--now", "tether-agent"]) + .status(); + let _ = std::fs::remove_file(systemd_unit_path()); + println!("✅ uninstalled systemd user service."); + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + eprintln!("nothing to uninstall on this OS."); +} + +fn status() { + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("launchctl") + .args(["list", "io.pecord.tether-agent"]) + .status(); + } + #[cfg(target_os = "linux")] + { + let _ = std::process::Command::new("systemctl") + .args(["--user", "status", "tether-agent"]) + .status(); + } +} + +#[cfg(target_os = "macos")] +fn launchd_plist_path() -> String { + format!( + "{}/Library/LaunchAgents/io.pecord.tether-agent.plist", + std::env::var("HOME").unwrap() + ) +} + +#[cfg(target_os = "macos")] +fn install_launchd(exe: &str, args: &[String]) { + let home = std::env::var("HOME").unwrap(); + std::fs::create_dir_all(format!("{home}/Library/LaunchAgents")).ok(); + let path = launchd_plist_path(); + let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); + let mut program = format!(" {}\n", esc(exe)); + for a in args { + program.push_str(&format!(" {}\n", esc(a))); + } + let log = format!("{home}/Library/Logs/tether-agent.log"); + let plist = format!( + r#" + + + + Labelio.pecord.tether-agent + ProgramArguments + +{program} + RunAtLoad + KeepAlive + StandardOutPath{log} + StandardErrorPath{log} + + +"# + ); + std::fs::write(&path, plist).expect("write plist"); + // Replace any prior load (quietly — errors if not loaded, which is fine). + let _ = std::process::Command::new("launchctl") + .args(["unload", &path]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + let ok = std::process::Command::new("launchctl") + .args(["load", "-w", &path]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ok { + println!("✅ installed: {path}\n runs now and on every login · logs → {log}\n stop with: tether-agent uninstall"); + } else { + eprintln!("wrote {path} but `launchctl load` failed"); + } +} + +#[cfg(target_os = "linux")] +fn systemd_unit_path() -> String { + format!( + "{}/.config/systemd/user/tether-agent.service", + std::env::var("HOME").unwrap() + ) +} + +#[cfg(target_os = "linux")] +fn install_systemd(exe: &str, args: &[String]) { + std::fs::create_dir_all(format!( + "{}/.config/systemd/user", + std::env::var("HOME").unwrap() + )) + .ok(); + let path = systemd_unit_path(); + // Quote each token so names with spaces survive systemd's ExecStart parsing. + let exec = std::iter::once(exe.to_string()) + .chain(args.iter().cloned()) + .map(|a| format!("\"{}\"", a.replace('"', "\\\""))) + .collect::>() + .join(" "); + let unit = format!( + "[Unit]\nDescription=tether clipboard agent\nAfter=network-online.target\n\n[Service]\nExecStart={exec}\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=default.target\n" + ); + std::fs::write(&path, unit).expect("write unit"); + let _ = std::process::Command::new("systemctl").args(["--user", "daemon-reload"]).status(); + let ok = std::process::Command::new("systemctl") + .args(["--user", "enable", "--now", "tether-agent"]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ok { + println!("✅ installed: {path}\n runs now and on login (`loginctl enable-linger` for boot w/o login)\n stop with: tether-agent uninstall"); + } else { + eprintln!("wrote {path} but `systemctl enable` failed"); + } +}