agent: install/uninstall as a login service (launchd / systemd)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 11:40:41 -05:00
parent 88ae0e6958
commit fdbc99d40e
2 changed files with 208 additions and 4 deletions

20
agent/README.md Normal file
View File

@@ -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.

View File

@@ -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::<String>::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<String> {
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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
let mut program = format!(" <string>{}</string>\n", esc(exe));
for a in args {
program.push_str(&format!(" <string>{}</string>\n", esc(a)));
}
let log = format!("{home}/Library/Logs/tether-agent.log");
let plist = 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>io.pecord.tether-agent</string>
<key>ProgramArguments</key>
<array>
{program} </array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>{log}</string>
<key>StandardErrorPath</key><string>{log}</string>
</dict>
</plist>
"#
);
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::<Vec<_>>()
.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");
}
}