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:
@@ -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.
|
||||||
+188
-4
@@ -49,11 +49,19 @@ fn arg(flag: &str, default: &str) -> String {
|
|||||||
.unwrap_or_else(|| default.to_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 server = arg("--server", "https://tether.pecord.io");
|
||||||
let source = arg("--source", "agent");
|
let source = arg("--source", "agent");
|
||||||
let from = arg("--from", &format!("agent-{source}"));
|
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 host = std::env::var("HOSTNAME").unwrap_or_else(|_| "tether-agent".into());
|
||||||
let name = arg("--name", &host);
|
let name = arg("--name", &host);
|
||||||
// Shared identity: same-account devices pair on the LAN regardless of room,
|
// Shared identity: same-account devices pair on the LAN regardless of room,
|
||||||
@@ -65,11 +73,25 @@ fn main() {
|
|||||||
tethercore::room_for_account(account.clone())
|
tethercore::room_for_account(account.clone())
|
||||||
};
|
};
|
||||||
let room = arg("--room", &default_room);
|
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 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())));
|
engine.start(Box::new(Inbox(inbox.clone())));
|
||||||
|
|
||||||
let mut clip = Clipboard::new().expect("open system clipboard");
|
let mut clip = Clipboard::new().expect("open system clipboard");
|
||||||
@@ -95,3 +117,165 @@ fn main() {
|
|||||||
sleep(Duration::from_millis(500));
|
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