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:
2963
agent/Cargo.lock
generated
2963
agent/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user