agent: §8 provenance toast on inbound + iroh feature

- On inbound clipboard (Option A: write straight to the OS clipboard), fire a
  non-blocking native toast — osascript on macOS, notify-send on Linux —
  "Copied from <device>", mapping the source label to a friendly name. The
  mandatory awareness from §8 so a remote clobber is never silent. Best-effort,
  never blocks the sync loop.
- Add an `iroh` feature (→ tethercore/iroh-transport) so the agent runs on the
  iroh Mesh; --server then points at the rendezvous. Both default and
  --features iroh builds verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:34:38 -05:00
parent eefd58cf5b
commit 33c577357c
3 changed files with 2966 additions and 86 deletions

2963
agent/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,3 +11,9 @@ path = "src/main.rs"
[dependencies]
tethercore = { path = "../core" }
arboard = "3"
[features]
default = []
# Build the agent on the iroh Mesh: `cargo build --features iroh`. Forwards to
# tethercore's iroh-transport, so the agent's --server becomes the rendezvous URL.
iroh = ["tethercore/iroh-transport"]

View File

@@ -1,14 +1,18 @@
//! tether-agent — headless clipboard sync for desktops.
//!
//! The same shared `tethercore` engine (SSE + RTC) 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.
//! 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]
//!
//! Inbound clipboard from the room is written to the local clipboard; local
//! clipboard changes are published to the room. Echo is suppressed by tracking
//! the last value we set or sent.
//! 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;
@@ -17,14 +21,21 @@ 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
/// text for the main loop, which owns the (non-Send) clipboard handle.
struct Inbox(Arc<Mutex<Vec<String>>>);
/// 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(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" });
@@ -46,6 +57,44 @@ impl MessageHandler for Inbox {
}
}
/// §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(not(any(target_os = "macos", target_os = "linux")))]
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 {
@@ -137,7 +186,7 @@ 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::<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())));
@@ -145,12 +194,14 @@ fn run() {
let mut last = clip.get_text().unwrap_or_default();
loop {
// Apply inbound: received text → OS clipboard.
let pending: Vec<String> = std::mem::take(&mut *inbox.lock().unwrap());
for text in pending {
if text != last {
let _ = clip.set_text(text.clone());
last = text;
// 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.