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:
20
agent/README.md
Normal file
20
agent/README.md
Normal 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.
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">");
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user