claude_utils/clipboard/
watcher.rs1use 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), Image(String, usize, usize), }
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 }
71 }
72 }
73
74 async fn check_clipboard(&self) -> Result<()> {
75 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 let content_hash = self.calculate_content_hash(¤t_data.content);
86 let content_type = self.get_content_type(¤t_data.content);
87
88 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 *last = Some(WatchedContent {
102 content_hash: content_hash.clone(),
103 timestamp: SystemTime::now(),
104 content_type: content_type.clone(),
105 });
106 drop(last); 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#[cfg(target_os = "macos")]
178pub mod platform {
179 use super::*;
180
181 pub struct DualClipboard;
182
183 impl DualClipboard {
184 pub fn set_dual_content(path: &str, _image_data: &[u8]) -> Result<()> {
187 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 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}