Files
tether/agent/src/main.rs
Patrick Ecord 5dfb2aa133 agent: §8 provenance toast on Windows (PowerShell/WinRT)
Adds the Windows arm to notify() — a WinRT toast via PowerShell (no extra
modules on Win10/11), completing the "Copied from <device>" provenance across all
three desktop OSes (macOS osascript, Linux notify-send, Windows toast).

Verified: `cargo check --target x86_64-pc-windows-gnu --features iroh` passes —
the whole agent incl. the new arm type-checks for Windows. The final binary LINK
can't be done via the macOS→windows-gnu mingw cross-toolchain (iroh-relay pulls
Windows system libs mingw doesn't auto-link); a native MSVC build is the real
target and is expected to link. Code-complete + type-checked; runtime untested
without a Windows box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:41:17 -05:00

398 lines
15 KiB
Rust

//! tether-agent — headless clipboard sync for desktops.
//!
//! The same shared `tethercore` engine that backs the native apps, wired to the
//! OS clipboard via `arboard`. One Rust binary covers macOS, Linux, and Windows.
//! No UI; meant to run as a login/service daemon.
//!
//! tether-agent --server http://host:8765 --room <id> [--source label]
//!
//! Default build uses the legacy transports; `--features iroh` builds it on the
//! iroh Mesh (then --server is the rendezvous URL).
//!
//! Inbound clipboard from the room is written to the local clipboard (§8 Option
//! A) and fires a non-blocking provenance toast ("Copied from <device>") so the
//! clobber is never silent; local clipboard changes are published. Echo is
//! suppressed by tracking the last value we set or sent.
use std::sync::{Arc, Mutex};
use std::thread::sleep;
use std::time::Duration;
use arboard::Clipboard;
use tethercore::{Engine, Message, MessageHandler};
/// An inbound clipboard with its provenance (who it came from), queued for the
/// main loop to apply to the OS clipboard.
struct Inbound {
text: String,
source: String, // sender's platform/device label, for the provenance toast
}
/// Inbound sink: the engine calls this on its runtime thread; we just queue the
/// item for the main loop, which owns the (non-Send) clipboard handle.
struct Inbox(Arc<Mutex<Vec<Inbound>>>);
impl MessageHandler for Inbox {
fn on_message(&self, msg: Message) {
eprintln!("← [{}] {}", msg.source, preview(&msg.text));
self.0.lock().unwrap().push(Inbound { text: msg.text, source: msg.source });
}
fn on_status(&self, connected: bool) {
eprintln!("{}", if connected { "connected" } else { "disconnected" });
}
fn on_file(&self, name: String, mime: String, data: Vec<u8>) {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
let safe: String = name
.chars()
.filter(|c| c.is_alphanumeric() || ".-_ ".contains(*c))
.collect();
let path = format!(
"{home}/Downloads/{}",
if safe.is_empty() { "tether-file".into() } else { safe }
);
match std::fs::write(&path, &data) {
Ok(_) => eprintln!("⬇ saved {path} ({} bytes, {mime})", data.len()),
Err(e) => eprintln!("{name} ({} bytes) — save failed: {e}", data.len()),
}
}
}
/// §8 provenance: a non-blocking native toast when a remote clipboard clobbers
/// the local one, so it's never silent ("Copied from <device>"). The mandatory
/// awareness for Option A. Best-effort — never blocks the sync loop.
fn notify(source: &str, text: &str) {
let who = friendly(source);
let body = preview(text);
#[cfg(target_os = "macos")]
{
// AppleScript notification — escape quotes/backslashes for the -e string.
let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
let script = format!(
"display notification \"{}\" with title \"tether\" subtitle \"Copied from {}\"",
esc(&body), esc(&who)
);
let _ = std::process::Command::new("osascript").args(["-e", &script]).status();
}
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("notify-send")
.args([&format!("tether — copied from {who}"), &body])
.status();
}
#[cfg(target_os = "windows")]
{
// Toast via PowerShell + WinRT — no extra modules on Win10/11.
let esc = |s: &str| s.replace('\'', "''"); // PowerShell single-quote escape
let script = format!(
"[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime]>$null;\
[Windows.UI.Notifications.ToastNotification,Windows.UI.Notifications,ContentType=WindowsRuntime]>$null;\
$tpl=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02);\
$t=$tpl.GetElementsByTagName('text');\
$t[0].InnerText='Copied from {who}';$t[1].InnerText='{body}';\
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('tether').Show([Windows.UI.Notifications.ToastNotification]::new($tpl))",
who = esc(&who),
body = esc(&body),
);
let _ = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.status();
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let _ = (who, body);
}
/// Map a source/platform label to something friendlier for the toast.
fn friendly(source: &str) -> String {
let s = source.to_lowercase();
if s.contains("iphone") || s.contains("ios") { "iPhone".into() }
else if s.contains("ipad") { "iPad".into() }
else if s.contains("mac") { "Mac".into() }
else if s.contains("windows") { "Windows".into() }
else if s.contains("android") { "Android".into() }
else if s.is_empty() { "another device".into() }
else { source.to_string() }
}
fn preview(s: &str) -> String {
let t = s.replace('\n', " ");
if t.chars().count() > 60 {
format!("{}", t.chars().take(60).collect::<String>())
} else {
t
}
}
fn arg(flag: &str, default: &str) -> String {
let a: Vec<String> = std::env::args().collect();
a.iter()
.position(|x| x == flag)
.and_then(|i| a.get(i + 1))
.cloned()
.unwrap_or_else(|| default.to_string())
}
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}"));
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,
// and share an account-derived room everywhere (so cross-network works too).
let account = arg("--account", "pecord@gmail.com");
let default_room = if account.is_empty() {
"default".to_string()
} else {
tethercore::room_for_account(account.clone())
};
let room = arg("--room", &default_room);
AgentConfig { server, room, source, from, name, account }
}
fn main() {
match std::env::args().nth(1).as_deref() {
Some("install") => install(),
Some("uninstall") => uninstall(),
Some("status") => status(),
Some("send-file") => send_file_once(),
// No subcommand (or --flags) → run in the foreground.
_ => run(),
}
}
/// `tether-agent send-file <path>` — connect, wait for a direct link, send the
/// file, exit. Handy for testing / one-shot sharing.
fn send_file_once() {
let path = std::env::args()
.nth(2)
.expect("usage: tether-agent send-file <path> [--server URL --account ID]");
let data = std::fs::read(&path).expect("read file");
let fname = std::path::Path::new(&path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".into());
let mime = match path.rsplit('.').next().map(str::to_lowercase).as_deref() {
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("pdf") => "application/pdf",
_ => "application/octet-stream",
}
.to_string();
let c = config();
let engine = Engine::new(c.server, c.room, c.from, c.source, c.name, c.account);
engine.start(Box::new(Inbox(Arc::new(Mutex::new(Vec::new())))));
eprintln!("connecting + waiting for a direct link…");
sleep(Duration::from_secs(10));
eprintln!("sending {fname} ({} bytes, {mime})…", data.len());
engine.send_file(fname, mime, data);
sleep(Duration::from_secs(5)); // let chunks flush
eprintln!("done");
}
fn run() {
let c = config();
eprintln!("tether-agent → {} room={} source={}", c.server, c.room, c.source);
let inbox = Arc::new(Mutex::new(Vec::<Inbound>::new()));
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");
let mut last = clip.get_text().unwrap_or_default();
loop {
// Apply inbound: received text → OS clipboard (Option A, §8). Each remote
// clobber fires a non-blocking provenance toast so it's never invisible.
let pending: Vec<Inbound> = std::mem::take(&mut *inbox.lock().unwrap());
for item in pending {
if item.text != last {
let _ = clip.set_text(item.text.clone());
notify(&item.source, &item.text);
last = item.text;
}
}
// Detect a local change → publish to the room.
if let Ok(cur) = clip.get_text() {
if !cur.is_empty() && cur != last {
last = cur.clone();
eprintln!("{}", preview(&cur));
engine.send(cur);
}
}
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");
}
}