Rust · 12285 bytes Raw Blame History
1 //! Clipboard synchronization module
2 //!
3 //! Handles clipboard sharing between HyprKVM machines.
4
5 mod hash;
6 mod wayland;
7
8 use std::collections::HashMap;
9 use std::sync::atomic::{AtomicU64, Ordering};
10 use std::time::Instant;
11
12 use tokio::sync::RwLock;
13 use tracing::{debug, info, warn};
14
15 use hyprkvm_common::protocol::{
16 ClipboardDataPayload, ClipboardOfferPayload, ClipboardRequestPayload,
17 };
18
19 use crate::config::ClipboardConfig;
20
21 pub use self::wayland::{is_image_mime, is_text_mime};
22
23 /// Chunk size for large clipboard data (64KB)
24 const CHUNK_SIZE: usize = 64 * 1024;
25
26 /// Timeout for incomplete chunk buffers (30 seconds)
27 const CHUNK_TIMEOUT_SECS: u64 = 30;
28
29 /// Clipboard synchronization error
30 #[derive(Debug, thiserror::Error)]
31 #[allow(dead_code)]
32 pub enum ClipboardError {
33 #[error("Clipboard access denied")]
34 AccessDenied,
35
36 #[error("Clipboard empty")]
37 Empty,
38
39 #[error("Content too large: {size} bytes (max: {max})")]
40 TooLarge { size: u64, max: u64 },
41
42 #[error("Unsupported content type")]
43 UnsupportedType,
44
45 #[error("Wayland error: {0}")]
46 Wayland(String),
47
48 #[error("IO error: {0}")]
49 Io(#[from] std::io::Error),
50
51 #[error("Base64 decode error: {0}")]
52 Base64(#[from] base64::DecodeError),
53
54 #[error("Chunk timeout")]
55 ChunkTimeout,
56
57 #[error("Chunk sequence error")]
58 ChunkSequence,
59 }
60
61 /// Buffer for reassembling chunked clipboard data
62 struct ChunkBuffer {
63 mime_type: String,
64 chunks: HashMap<u32, Vec<u8>>,
65 total_chunks: u32,
66 received_at: Instant,
67 }
68
69 impl ChunkBuffer {
70 fn new(mime_type: String, total_chunks: u32) -> Self {
71 Self {
72 mime_type,
73 chunks: HashMap::new(),
74 total_chunks,
75 received_at: Instant::now(),
76 }
77 }
78
79 fn is_complete(&self) -> bool {
80 self.chunks.len() == self.total_chunks as usize
81 }
82
83 fn is_expired(&self) -> bool {
84 self.received_at.elapsed().as_secs() > CHUNK_TIMEOUT_SECS
85 }
86
87 fn add_chunk(&mut self, index: u32, data: Vec<u8>) {
88 self.chunks.insert(index, data);
89 }
90
91 fn reassemble(&self) -> Option<Vec<u8>> {
92 if !self.is_complete() {
93 return None;
94 }
95
96 let mut result = Vec::new();
97 for i in 0..self.total_chunks {
98 if let Some(chunk) = self.chunks.get(&i) {
99 result.extend_from_slice(chunk);
100 } else {
101 return None;
102 }
103 }
104 Some(result)
105 }
106 }
107
108 /// Manages clipboard synchronization
109 pub struct ClipboardManager {
110 config: ClipboardConfig,
111 clipboard_id_counter: AtomicU64,
112 last_content_hash: RwLock<Option<String>>,
113 pending_chunks: RwLock<HashMap<u64, ChunkBuffer>>,
114 }
115
116 impl ClipboardManager {
117 /// Create a new clipboard manager
118 pub fn new(config: ClipboardConfig) -> Self {
119 Self {
120 config,
121 clipboard_id_counter: AtomicU64::new(1),
122 last_content_hash: RwLock::new(None),
123 pending_chunks: RwLock::new(HashMap::new()),
124 }
125 }
126
127 /// Generate a new clipboard ID
128 fn next_clipboard_id(&self) -> u64 {
129 self.clipboard_id_counter.fetch_add(1, Ordering::SeqCst)
130 }
131
132 /// Read clipboard and create an offer (if enabled and within limits)
133 pub async fn create_offer(&self) -> Result<Option<ClipboardOfferPayload>, ClipboardError> {
134 if !self.config.enabled {
135 return Ok(None);
136 }
137
138 // Get available MIME types
139 let mime_types = match wayland::get_available_mime_types() {
140 Ok(types) => types,
141 Err(ClipboardError::Empty) => {
142 debug!("Clipboard is empty, nothing to offer");
143 return Ok(None);
144 }
145 Err(e) => return Err(e),
146 };
147
148 if mime_types.is_empty() {
149 debug!("No MIME types available in clipboard");
150 return Ok(None);
151 }
152
153 // Filter MIME types based on config
154 let filtered_types: Vec<String> = mime_types
155 .into_iter()
156 .filter(|m| {
157 (self.config.sync_images && is_image_mime(m))
158 || (self.config.sync_text && is_text_mime(m))
159 })
160 .collect();
161
162 if filtered_types.is_empty() {
163 debug!("No supported MIME types in clipboard");
164 return Ok(None);
165 }
166
167 // Select best MIME type and read content for hash
168 let preferred_mime =
169 wayland::select_mime_type(&filtered_types, self.config.sync_images, self.config.sync_text);
170
171 let (data, actual_mime) = match preferred_mime {
172 Some(ref mime) => wayland::read_clipboard(Some(mime))?,
173 None => return Ok(None),
174 };
175
176 let size = data.len() as u64;
177
178 // Check size limit
179 if size > self.config.max_size {
180 warn!(
181 "Clipboard content too large: {} bytes (max: {}), skipping sync",
182 size, self.config.max_size
183 );
184 return Ok(None);
185 }
186
187 // Compute content hash for deduplication
188 let content_hash = hash::compute_hash(&data);
189
190 // Check if content is same as last synced
191 {
192 let last_hash = self.last_content_hash.read().await;
193 if last_hash.as_ref() == Some(&content_hash) {
194 debug!("Clipboard content unchanged (hash match), skipping sync");
195 return Ok(None);
196 }
197 }
198
199 let clipboard_id = self.next_clipboard_id();
200
201 info!(
202 "Creating clipboard offer: id={}, mime={}, size={} bytes",
203 clipboard_id, actual_mime, size
204 );
205
206 Ok(Some(ClipboardOfferPayload {
207 clipboard_id,
208 mime_types: filtered_types,
209 size_hint: Some(size),
210 content_hash: Some(content_hash),
211 }))
212 }
213
214 /// Handle incoming offer, return request if interested
215 pub async fn handle_offer(
216 &self,
217 offer: ClipboardOfferPayload,
218 ) -> Option<ClipboardRequestPayload> {
219 if !self.config.enabled {
220 return None;
221 }
222
223 // Check for duplicate content via hash
224 if let Some(ref hash) = offer.content_hash {
225 let last_hash = self.last_content_hash.read().await;
226 if last_hash.as_ref() == Some(hash) {
227 debug!(
228 "Clipboard offer {} has same hash as current, skipping",
229 offer.clipboard_id
230 );
231 return None;
232 }
233 }
234
235 // Check size limit
236 if let Some(size) = offer.size_hint {
237 if size > self.config.max_size {
238 warn!(
239 "Clipboard offer {} too large: {} bytes (max: {}), skipping",
240 offer.clipboard_id, size, self.config.max_size
241 );
242 return None;
243 }
244 }
245
246 // Select preferred MIME type
247 let mime_type = wayland::select_mime_type(
248 &offer.mime_types,
249 self.config.sync_images,
250 self.config.sync_text,
251 )?;
252
253 info!(
254 "Requesting clipboard {}: mime={}",
255 offer.clipboard_id, mime_type
256 );
257
258 Some(ClipboardRequestPayload {
259 clipboard_id: offer.clipboard_id,
260 mime_type,
261 })
262 }
263
264 /// Handle incoming request, return data chunks
265 pub async fn handle_request(
266 &self,
267 request: ClipboardRequestPayload,
268 ) -> Result<Vec<ClipboardDataPayload>, ClipboardError> {
269 if !self.config.enabled {
270 return Ok(vec![]);
271 }
272
273 info!(
274 "Handling clipboard request {}: mime={}",
275 request.clipboard_id, request.mime_type
276 );
277
278 // Read clipboard with requested MIME type
279 let (data, _actual_mime) = wayland::read_clipboard(Some(&request.mime_type))?;
280
281 // Check size limit
282 if data.len() as u64 > self.config.max_size {
283 warn!(
284 "Clipboard content too large for request: {} bytes",
285 data.len()
286 );
287 return Err(ClipboardError::TooLarge {
288 size: data.len() as u64,
289 max: self.config.max_size,
290 });
291 }
292
293 // Chunk the data
294 let chunks = self.chunk_data(&data, request.clipboard_id, &request.mime_type);
295
296 info!(
297 "Sending clipboard {}: {} bytes in {} chunk(s)",
298 request.clipboard_id,
299 data.len(),
300 chunks.len()
301 );
302
303 Ok(chunks)
304 }
305
306 /// Chunk data for transmission
307 fn chunk_data(
308 &self,
309 data: &[u8],
310 clipboard_id: u64,
311 mime_type: &str,
312 ) -> Vec<ClipboardDataPayload> {
313 use base64::Engine;
314 let engine = base64::engine::general_purpose::STANDARD;
315
316 let total_chunks = (data.len() + CHUNK_SIZE - 1) / CHUNK_SIZE;
317
318 if total_chunks <= 1 {
319 // Single chunk, no chunking needed
320 return vec![ClipboardDataPayload {
321 clipboard_id,
322 mime_type: mime_type.to_string(),
323 data: engine.encode(data),
324 chunk_index: None,
325 total_chunks: None,
326 }];
327 }
328
329 data.chunks(CHUNK_SIZE)
330 .enumerate()
331 .map(|(i, chunk)| ClipboardDataPayload {
332 clipboard_id,
333 mime_type: mime_type.to_string(),
334 data: engine.encode(chunk),
335 chunk_index: Some(i as u32),
336 total_chunks: Some(total_chunks as u32),
337 })
338 .collect()
339 }
340
341 /// Handle incoming data chunk
342 pub async fn handle_data(&self, data: ClipboardDataPayload) -> Result<(), ClipboardError> {
343 if !self.config.enabled {
344 return Ok(());
345 }
346
347 use base64::Engine;
348 let engine = base64::engine::general_purpose::STANDARD;
349
350 // Decode base64 data
351 let decoded = engine.decode(&data.data)?;
352
353 // Check if this is a chunked transfer
354 match (data.chunk_index, data.total_chunks) {
355 (Some(index), Some(total)) => {
356 // Chunked transfer
357 debug!(
358 "Received clipboard {} chunk {}/{}",
359 data.clipboard_id,
360 index + 1,
361 total
362 );
363
364 let mut pending = self.pending_chunks.write().await;
365
366 // Clean up expired buffers
367 pending.retain(|_, buf| !buf.is_expired());
368
369 // Get or create chunk buffer
370 let buffer = pending
371 .entry(data.clipboard_id)
372 .or_insert_with(|| ChunkBuffer::new(data.mime_type.clone(), total));
373
374 buffer.add_chunk(index, decoded);
375
376 if buffer.is_complete() {
377 // Reassemble and set clipboard
378 if let Some(full_data) = buffer.reassemble() {
379 let mime_type = buffer.mime_type.clone();
380 pending.remove(&data.clipboard_id);
381
382 info!(
383 "Clipboard {} complete: {} bytes, setting clipboard",
384 data.clipboard_id,
385 full_data.len()
386 );
387
388 // Update hash before setting
389 let hash = hash::compute_hash(&full_data);
390 *self.last_content_hash.write().await = Some(hash);
391
392 wayland::set_clipboard(&full_data, &mime_type)?;
393 }
394 }
395 }
396 _ => {
397 // Single chunk (non-chunked transfer)
398 info!(
399 "Received clipboard {}: {} bytes, setting clipboard",
400 data.clipboard_id,
401 decoded.len()
402 );
403
404 // Update hash before setting
405 let hash = hash::compute_hash(&decoded);
406 *self.last_content_hash.write().await = Some(hash);
407
408 wayland::set_clipboard(&decoded, &data.mime_type)?;
409 }
410 }
411
412 Ok(())
413 }
414 }
415