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:
Generated
+2893
-70
File diff suppressed because it is too large
Load Diff
@@ -11,3 +11,9 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tethercore = { path = "../core" }
|
tethercore = { path = "../core" }
|
||||||
arboard = "3"
|
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"]
|
||||||
|
|||||||
+67
-16
@@ -1,14 +1,18 @@
|
|||||||
//! tether-agent — headless clipboard sync for desktops.
|
//! tether-agent — headless clipboard sync for desktops.
|
||||||
//!
|
//!
|
||||||
//! The same shared `tethercore` engine (SSE + RTC) that backs the native apps,
|
//! The same shared `tethercore` engine that backs the native apps, wired to the
|
||||||
//! wired to the OS clipboard via `arboard`. One Rust binary covers macOS, Linux,
|
//! OS clipboard via `arboard`. One Rust binary covers macOS, Linux, and Windows.
|
||||||
//! and Windows. No UI; meant to run as a login/service daemon.
|
//! No UI; meant to run as a login/service daemon.
|
||||||
//!
|
//!
|
||||||
//! tether-agent --server http://host:8765 --room <id> [--source label]
|
//! tether-agent --server http://host:8765 --room <id> [--source label]
|
||||||
//!
|
//!
|
||||||
//! Inbound clipboard from the room is written to the local clipboard; local
|
//! Default build uses the legacy transports; `--features iroh` builds it on the
|
||||||
//! clipboard changes are published to the room. Echo is suppressed by tracking
|
//! iroh Mesh (then --server is the rendezvous URL).
|
||||||
//! the last value we set or sent.
|
//!
|
||||||
|
//! 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::sync::{Arc, Mutex};
|
||||||
use std::thread::sleep;
|
use std::thread::sleep;
|
||||||
@@ -17,14 +21,21 @@ use std::time::Duration;
|
|||||||
use arboard::Clipboard;
|
use arboard::Clipboard;
|
||||||
use tethercore::{Engine, Message, MessageHandler};
|
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
|
/// 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.
|
/// item for the main loop, which owns the (non-Send) clipboard handle.
|
||||||
struct Inbox(Arc<Mutex<Vec<String>>>);
|
struct Inbox(Arc<Mutex<Vec<Inbound>>>);
|
||||||
|
|
||||||
impl MessageHandler for Inbox {
|
impl MessageHandler for Inbox {
|
||||||
fn on_message(&self, msg: Message) {
|
fn on_message(&self, msg: Message) {
|
||||||
eprintln!("← [{}] {}", msg.source, preview(&msg.text));
|
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) {
|
fn on_status(&self, connected: bool) {
|
||||||
eprintln!("• {}", if connected { "connected" } else { "disconnected" });
|
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 {
|
fn preview(s: &str) -> String {
|
||||||
let t = s.replace('\n', " ");
|
let t = s.replace('\n', " ");
|
||||||
if t.chars().count() > 60 {
|
if t.chars().count() > 60 {
|
||||||
@@ -137,7 +186,7 @@ fn run() {
|
|||||||
let c = config();
|
let c = config();
|
||||||
eprintln!("tether-agent → {} room={} source={}", c.server, c.room, c.source);
|
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);
|
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())));
|
||||||
|
|
||||||
@@ -145,12 +194,14 @@ fn run() {
|
|||||||
let mut last = clip.get_text().unwrap_or_default();
|
let mut last = clip.get_text().unwrap_or_default();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Apply inbound: received text → OS clipboard.
|
// Apply inbound: received text → OS clipboard (Option A, §8). Each remote
|
||||||
let pending: Vec<String> = std::mem::take(&mut *inbox.lock().unwrap());
|
// clobber fires a non-blocking provenance toast so it's never invisible.
|
||||||
for text in pending {
|
let pending: Vec<Inbound> = std::mem::take(&mut *inbox.lock().unwrap());
|
||||||
if text != last {
|
for item in pending {
|
||||||
let _ = clip.set_text(text.clone());
|
if item.text != last {
|
||||||
last = text;
|
let _ = clip.set_text(item.text.clone());
|
||||||
|
notify(&item.source, &item.text);
|
||||||
|
last = item.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Detect a local change → publish to the room.
|
// Detect a local change → publish to the room.
|
||||||
|
|||||||
Reference in New Issue
Block a user