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>
398 lines
15 KiB
Rust
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('&', "&").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");
|
|
}
|
|
}
|