tenseleyflow/hyprkvm / 7023e90

Browse files

feat: add clipboard module for Wayland clipboard access

- ClipboardManager for offer/request/data flow
- Wayland access via wl-clipboard-rs (wlr-data-control protocol)
- SHA256 content hashing for deduplication
- MIME type prioritization (images > text)
- 64KB chunking for large content
- Support for text and image clipboard sync
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7023e90a7cb02721db4772545d1529ae84a94b7c
Parents
0dd9281
Tree
061bce6

3 changed files

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