Skip to main content

claude_utils/clipboard/
watcher.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::{Duration, SystemTime};
4use tokio::sync::{mpsc, RwLock};
5use tokio::time::{interval, MissedTickBehavior};
6use tracing::{debug, error, info, warn};
7
8use super::{ClipboardContent, ClipboardData, ClipboardManager};
9use crate::Result;
10
11#[derive(Debug, Clone, PartialEq)]
12pub struct WatchedContent {
13    pub content_hash: String,
14    pub timestamp: SystemTime,
15    pub content_type: ContentType,
16}
17
18#[derive(Debug, Clone, PartialEq)]
19pub enum ContentType {
20    Text(usize),                 // size
21    Image(String, usize, usize), // format, width, height
22}
23
24#[derive(Debug, Clone)]
25pub struct ClipboardEvent {
26    pub content: ClipboardData,
27    pub staged_path: Option<PathBuf>,
28    pub symlink_path: Option<PathBuf>,
29}
30
31pub struct ClipboardWatcher {
32    clipboard: Arc<ClipboardManager>,
33    last_content: Arc<RwLock<Option<WatchedContent>>>,
34    poll_interval: Duration,
35    event_sender: mpsc::Sender<ClipboardEvent>,
36}
37
38impl ClipboardWatcher {
39    pub fn new(
40        clipboard: Arc<ClipboardManager>,
41        poll_interval: Duration,
42    ) -> (Self, mpsc::Receiver<ClipboardEvent>) {
43        let (tx, rx) = mpsc::channel(100);
44
45        let watcher = Self {
46            clipboard,
47            last_content: Arc::new(RwLock::new(None)),
48            poll_interval,
49            event_sender: tx,
50        };
51
52        (watcher, rx)
53    }
54
55    pub async fn start_watching(self) {
56        let mut interval_timer = interval(self.poll_interval);
57        interval_timer.set_missed_tick_behavior(MissedTickBehavior::Skip);
58
59        info!(
60            "Clipboard watcher started (poll interval: {:?})",
61            self.poll_interval
62        );
63
64        loop {
65            interval_timer.tick().await;
66
67            if let Err(e) = self.check_clipboard().await {
68                error!("Clipboard check error: {}", e);
69                // Continue watching despite errors
70            }
71        }
72    }
73
74    async fn check_clipboard(&self) -> Result<()> {
75        // Get current clipboard content
76        let current_data = match self.clipboard.get_content() {
77            Ok(data) => data,
78            Err(e) => {
79                debug!("No clipboard content or error: {}", e);
80                return Ok(());
81            }
82        };
83
84        // Calculate content hash
85        let content_hash = self.calculate_content_hash(&current_data.content);
86        let content_type = self.get_content_type(&current_data.content);
87
88        // Check if content changed
89        let mut last = self.last_content.write().await;
90
91        let changed = match &*last {
92            Some(prev) => prev.content_hash != content_hash,
93            None => true,
94        };
95
96        if !changed {
97            return Ok(());
98        }
99
100        // Update last content
101        *last = Some(WatchedContent {
102            content_hash: content_hash.clone(),
103            timestamp: SystemTime::now(),
104            content_type: content_type.clone(),
105        });
106        drop(last); // Release write lock
107
108        // Emit event for new content
109        info!("New clipboard content detected: {:?}", content_type);
110
111        let event = ClipboardEvent {
112            content: current_data,
113            staged_path: None,
114            symlink_path: None,
115        };
116
117        if let Err(e) = self.event_sender.send(event).await {
118            warn!("Failed to send clipboard event: {}", e);
119        }
120
121        Ok(())
122    }
123
124    fn calculate_content_hash(&self, content: &ClipboardContent) -> String {
125        use sha2::{Digest, Sha256};
126        let mut hasher = Sha256::new();
127
128        match content {
129            ClipboardContent::Text { data, .. } => {
130                hasher.update(b"text:");
131                hasher.update(data.as_bytes());
132            }
133            ClipboardContent::ImagePng {
134                data,
135                file,
136                width,
137                height,
138                size,
139            }
140            | ClipboardContent::ImageJpeg {
141                data,
142                file,
143                width,
144                height,
145                size,
146            } => {
147                hasher.update(b"image:");
148                hasher.update(width.to_le_bytes());
149                hasher.update(height.to_le_bytes());
150                hasher.update(size.to_le_bytes());
151
152                if let Some(data) = data {
153                    hasher.update(data.as_bytes());
154                } else if let Some(file) = file {
155                    hasher.update(file.as_bytes());
156                }
157            }
158        }
159
160        format!("{:x}", hasher.finalize())
161    }
162
163    fn get_content_type(&self, content: &ClipboardContent) -> ContentType {
164        match content {
165            ClipboardContent::Text { data, .. } => ContentType::Text(data.len()),
166            ClipboardContent::ImagePng { width, height, .. } => {
167                ContentType::Image("png".to_string(), *width, *height)
168            }
169            ClipboardContent::ImageJpeg { width, height, .. } => {
170                ContentType::Image("jpeg".to_string(), *width, *height)
171            }
172        }
173    }
174}
175
176// Platform-specific clipboard manager that can handle dual formats
177#[cfg(target_os = "macos")]
178pub mod platform {
179    use super::*;
180
181    pub struct DualClipboard;
182
183    impl DualClipboard {
184        /// Sets both text (file path) and image data in clipboard
185        /// Terminal apps will get the text, image apps will get the image
186        pub fn set_dual_content(path: &str, _image_data: &[u8]) -> Result<()> {
187            // For now, let's use a simpler approach that definitely works
188            // We'll just set the text path, and document that dual format
189            // requires more complex macOS integration
190
191            let clipboard = ClipboardManager::new()?;
192            clipboard.set_content(&ClipboardContent::Text {
193                data: path.to_string(),
194                truncated: None,
195            })?;
196
197            warn!("Dual clipboard format not fully implemented on macOS yet");
198            Ok(())
199        }
200    }
201}
202
203#[cfg(not(target_os = "macos"))]
204pub mod platform {
205    use super::*;
206
207    pub struct DualClipboard;
208
209    impl DualClipboard {
210        pub fn set_dual_content(path: &str, _image_data: &[u8]) -> Result<()> {
211            // On other platforms, we'll just set the path as text
212            // This is a fallback - could implement X11/Win32 specific code
213            warn!("Dual clipboard not fully implemented for this platform");
214
215            let clipboard = ClipboardManager::new()?;
216            clipboard.set_content(&ClipboardContent::Text {
217                data: path.to_string(),
218                truncated: None,
219            })
220        }
221    }
222}