Skip to content

Commit d544e9c

Browse files
committed
implement (small, up to 4k) clipboard sharing over DTLS
1 parent 94e6372 commit d544e9c

File tree

22 files changed

+1813
-649
lines changed

22 files changed

+1813
-649
lines changed

Cargo.lock

Lines changed: 1000 additions & 590 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

input-capture/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ tokio = { version = "1.32.0", features = [
2727
once_cell = "1.19.0"
2828
async-trait = "0.1.81"
2929
tokio-util = "0.7.11"
30+
arboard = { version = "3.4", features = ["wayland-data-control"] }
3031

3132

3233
[target.'cfg(all(unix, not(target_os="macos")))'.dependencies]

input-capture/src/clipboard.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
use arboard::Clipboard;
2+
use input_event::{ClipboardEvent, Event};
3+
use std::sync::{Arc, Mutex};
4+
use std::time::{Duration, Instant};
5+
use tokio::sync::mpsc::{self, Receiver, Sender};
6+
use tokio::task::spawn_blocking;
7+
use tokio::time::interval;
8+
9+
use crate::{CaptureError, CaptureEvent};
10+
11+
/// Clipboard monitor that watches for clipboard changes
12+
pub struct ClipboardMonitor {
13+
event_rx: Receiver<CaptureEvent>,
14+
_event_tx: Sender<CaptureEvent>,
15+
last_content: Arc<Mutex<Option<String>>>,
16+
last_change: Arc<Mutex<Option<Instant>>>,
17+
enabled: Arc<Mutex<bool>>,
18+
}
19+
20+
impl ClipboardMonitor {
21+
pub fn new() -> Result<Self, CaptureError> {
22+
let (event_tx, event_rx) = mpsc::channel(16);
23+
let last_content: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
24+
let last_change: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
25+
let enabled = Arc::new(Mutex::new(true));
26+
27+
let last_content_clone = last_content.clone();
28+
let last_change_clone = last_change.clone();
29+
let enabled_clone = enabled.clone();
30+
let event_tx_clone = event_tx.clone();
31+
32+
// Spawn monitoring task
33+
tokio::spawn(async move {
34+
let mut check_interval = interval(Duration::from_millis(500));
35+
36+
loop {
37+
check_interval.tick().await;
38+
39+
// Check if enabled
40+
let is_enabled = {
41+
let enabled = enabled_clone.lock().unwrap();
42+
*enabled
43+
};
44+
45+
if !is_enabled {
46+
continue;
47+
}
48+
49+
// Read clipboard in blocking task
50+
let last_content_clone2 = last_content_clone.clone();
51+
let last_change_clone2 = last_change_clone.clone();
52+
let event_tx_clone2 = event_tx_clone.clone();
53+
54+
let _ = spawn_blocking(move || {
55+
// Create clipboard instance
56+
let mut clipboard = match Clipboard::new() {
57+
Ok(c) => c,
58+
Err(e) => {
59+
log::debug!("Failed to create clipboard: {}", e);
60+
return;
61+
}
62+
};
63+
64+
// Get current clipboard text
65+
let current_text = match clipboard.get_text() {
66+
Ok(text) => {
67+
log::trace!("Clipboard text read: {} bytes", text.len());
68+
text
69+
}
70+
Err(e) => {
71+
// Clipboard might be empty or contain non-text data
72+
log::trace!("Failed to get clipboard text: {}", e);
73+
return;
74+
}
75+
};
76+
77+
// Check if content changed
78+
let mut last_content = last_content_clone2.lock().unwrap();
79+
let mut last_change = last_change_clone2.lock().unwrap();
80+
81+
let content_changed = match last_content.as_ref() {
82+
None => true,
83+
Some(last) => last != &current_text,
84+
};
85+
86+
if content_changed {
87+
// Debounce: ignore changes within 200ms of last change
88+
// This prevents infinite loops when both sides update clipboard
89+
let should_emit = match *last_change {
90+
None => true,
91+
Some(instant) => instant.elapsed() > Duration::from_millis(200),
92+
};
93+
94+
if should_emit {
95+
log::info!("Clipboard changed, length: {} bytes", current_text.len());
96+
*last_content = Some(current_text.clone());
97+
*last_change = Some(Instant::now());
98+
99+
// Send event
100+
let event = CaptureEvent::Input(Event::Clipboard(
101+
ClipboardEvent::Text(current_text),
102+
));
103+
let _ = event_tx_clone2.blocking_send(event);
104+
} else {
105+
log::trace!("Clipboard changed but debounced (too recent)");
106+
}
107+
}
108+
})
109+
.await;
110+
}
111+
});
112+
113+
Ok(Self {
114+
event_rx,
115+
_event_tx: event_tx,
116+
last_content,
117+
last_change,
118+
enabled,
119+
})
120+
}
121+
122+
/// Receive the next clipboard event
123+
pub async fn recv(&mut self) -> Option<CaptureEvent> {
124+
self.event_rx.recv().await
125+
}
126+
127+
/// Enable clipboard monitoring
128+
pub fn enable(&self) {
129+
let mut enabled = self.enabled.lock().unwrap();
130+
*enabled = true;
131+
log::info!("Clipboard monitoring enabled");
132+
}
133+
134+
/// Disable clipboard monitoring
135+
pub fn disable(&self) {
136+
let mut enabled = self.enabled.lock().unwrap();
137+
*enabled = false;
138+
log::info!("Clipboard monitoring disabled");
139+
}
140+
141+
/// Update the last known clipboard content (called when we set the clipboard)
142+
/// This prevents detecting our own clipboard changes as external changes
143+
pub fn update_last_content(&self, content: String) {
144+
let mut last_content = self.last_content.lock().unwrap();
145+
let mut last_change = self.last_change.lock().unwrap();
146+
*last_content = Some(content);
147+
*last_change = Some(Instant::now());
148+
}
149+
}

input-capture/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use input_event::{scancode, Event, KeyboardEvent};
1313

1414
pub use error::{CaptureCreationError, CaptureError, InputCaptureError};
1515

16+
pub mod clipboard;
1617
pub mod error;
1718

1819
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
@@ -35,7 +36,7 @@ mod dummy;
3536

3637
pub type CaptureHandle = u64;
3738

38-
#[derive(Copy, Clone, Debug, PartialEq)]
39+
#[derive(Clone, Debug, PartialEq)]
3940
pub enum CaptureEvent {
4041
/// capture on this capture handle is now active
4142
Begin,
@@ -252,7 +253,7 @@ impl Stream for InputCapture {
252253
swap(&mut self.position_map, &mut position_map);
253254
{
254255
for &id in position_map.get(&pos).expect("position") {
255-
self.pending.push_back((id, event));
256+
self.pending.push_back((id, event.clone()));
256257
}
257258
}
258259
swap(&mut self.position_map, &mut position_map);

input-capture/src/macos.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ fn create_event_tap<'a>(
392392
res_events.iter().for_each(|e| {
393393
// error must be ignored, since the event channel
394394
// may already be closed when the InputCapture instance is dropped.
395-
let _ = event_tx.blocking_send((pos, *e));
395+
let _ = event_tx.blocking_send((pos, e.clone()));
396396
});
397397
// Returning Drop should stop the event from being processed
398398
// but core fundation still returns the event

input-emulation/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ tokio = { version = "1.32.0", features = [
2323
"signal",
2424
] }
2525
once_cell = "1.19.0"
26+
arboard = { version = "3.4", features = ["wayland-data-control"] }
2627

2728
[target.'cfg(all(unix, not(target_os="macos")))'.dependencies]
2829
bitflags = "2.6.0"

input-emulation/src/clipboard.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
use arboard::Clipboard;
2+
use input_event::ClipboardEvent;
3+
use std::sync::{Arc, Mutex};
4+
use thiserror::Error;
5+
use tokio::task::spawn_blocking;
6+
7+
#[derive(Debug, Error)]
8+
pub enum ClipboardError {
9+
#[error("Failed to access clipboard: {0}")]
10+
Access(String),
11+
#[error("Failed to set clipboard: {0}")]
12+
Set(String),
13+
}
14+
15+
/// Clipboard emulation that sets clipboard content
16+
#[derive(Clone)]
17+
pub struct ClipboardEmulation {
18+
// Use Arc<Mutex<>> to share clipboard across threads
19+
clipboard: Arc<Mutex<Option<Clipboard>>>,
20+
}
21+
22+
impl ClipboardEmulation {
23+
pub fn new() -> Result<Self, ClipboardError> {
24+
// Try to create initial clipboard instance
25+
let clipboard = match Clipboard::new() {
26+
Ok(c) => Some(c),
27+
Err(e) => {
28+
log::warn!("Failed to create clipboard instance: {}", e);
29+
None
30+
}
31+
};
32+
33+
Ok(Self {
34+
clipboard: Arc::new(Mutex::new(clipboard)),
35+
})
36+
}
37+
38+
/// Set clipboard content from a clipboard event
39+
pub async fn set(&self, event: ClipboardEvent) -> Result<(), ClipboardError> {
40+
match event {
41+
ClipboardEvent::Text(text) => {
42+
let clipboard_arc = self.clipboard.clone();
43+
44+
spawn_blocking(move || {
45+
let mut clipboard_guard = clipboard_arc.lock().unwrap();
46+
47+
// Try to get or create clipboard
48+
let clipboard = match clipboard_guard.as_mut() {
49+
Some(c) => c,
50+
None => {
51+
// Try to create a new clipboard instance
52+
match Clipboard::new() {
53+
Ok(c) => {
54+
*clipboard_guard = Some(c);
55+
clipboard_guard.as_mut().unwrap()
56+
}
57+
Err(e) => {
58+
return Err(ClipboardError::Access(format!("{}", e)));
59+
}
60+
}
61+
}
62+
};
63+
64+
// Set clipboard text
65+
clipboard
66+
.set_text(text.clone())
67+
.map_err(|e| ClipboardError::Set(format!("{}", e)))?;
68+
69+
log::debug!("Clipboard set, length: {} bytes", text.len());
70+
Ok(())
71+
})
72+
.await
73+
.map_err(|e| ClipboardError::Access(format!("Task join error: {}", e)))?
74+
}
75+
}
76+
}
77+
78+
/// Get current clipboard content (for testing/verification)
79+
pub async fn get(&self) -> Result<String, ClipboardError> {
80+
let clipboard_arc = self.clipboard.clone();
81+
82+
spawn_blocking(move || {
83+
let mut clipboard_guard = clipboard_arc.lock().unwrap();
84+
85+
let clipboard = match clipboard_guard.as_mut() {
86+
Some(c) => c,
87+
None => match Clipboard::new() {
88+
Ok(c) => {
89+
*clipboard_guard = Some(c);
90+
clipboard_guard.as_mut().unwrap()
91+
}
92+
Err(e) => {
93+
return Err(ClipboardError::Access(format!("{}", e)));
94+
}
95+
},
96+
};
97+
98+
clipboard
99+
.get_text()
100+
.map_err(|e| ClipboardError::Access(format!("{}", e)))
101+
})
102+
.await
103+
.map_err(|e| ClipboardError::Access(format!("Task join error: {}", e)))?
104+
}
105+
}

input-emulation/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ mod libei;
2626
#[cfg(target_os = "macos")]
2727
mod macos;
2828

29+
pub mod clipboard;
2930
/// fallback input emulation (logs events)
3031
mod dummy;
3132
mod error;

input-emulation/src/libei.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ impl Emulation for LibeiEmulation<'_> {
203203
}
204204
KeyboardEvent::Modifiers { .. } => {}
205205
},
206+
Event::Clipboard(_) => {
207+
// Clipboard events are not supported by libei emulation
208+
log::debug!("ignoring clipboard event in libei emulation");
209+
}
206210
}
207211
self.context
208212
.flush()

input-emulation/src/macos.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ impl Emulation for MacOSEmulation {
225225
_handle: EmulationHandle,
226226
) -> Result<(), EmulationError> {
227227
match event {
228+
Event::Clipboard(_) => {
229+
// Clipboard events are not emulated through this backend
230+
// They are handled directly by the clipboard emulation module
231+
}
228232
Event::Pointer(pointer_event) => match pointer_event {
229233
PointerEvent::Motion { time: _, dx, dy } => {
230234
let mut mouse_location = match self.get_mouse_location() {

0 commit comments

Comments
 (0)