claude_utils/clipboard/
processor.rs1use chrono::Local;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use tokio::fs;
5use tokio::sync::mpsc;
6use tracing::{error, info, warn};
7
8use super::{
9 watcher::{platform::DualClipboard, ClipboardEvent},
10 ClipboardContent,
11};
12use crate::{file_manager::FileManager, Result};
13
14#[derive(Debug, Clone)]
15pub struct ProcessorConfig {
16 pub symlink_dir: PathBuf,
17 pub symlink_prefix: String,
18 pub keep_symlinks: usize,
19 pub enable_dual_format: bool,
20 pub enable_notifications: bool,
21}
22
23impl Default for ProcessorConfig {
24 fn default() -> Self {
25 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
26
27 Self {
28 symlink_dir: home.join("Desktop"),
29 symlink_prefix: "claude-paste".to_string(),
30 keep_symlinks: 5,
31 enable_dual_format: true,
32 enable_notifications: true,
33 }
34 }
35}
36
37pub struct ClipboardProcessor {
38 config: ProcessorConfig,
39 file_manager: Arc<FileManager>,
40 clipboard_manager: Arc<super::ClipboardManager>,
41}
42
43impl ClipboardProcessor {
44 pub fn new(
45 config: ProcessorConfig,
46 file_manager: Arc<FileManager>,
47 clipboard_manager: Arc<super::ClipboardManager>,
48 ) -> Self {
49 Self {
50 config,
51 file_manager,
52 clipboard_manager,
53 }
54 }
55
56 pub async fn start_processing(self, mut event_rx: mpsc::Receiver<ClipboardEvent>) {
57 info!("Clipboard processor started");
58
59 while let Some(mut event) = event_rx.recv().await {
60 if let Err(e) = self.process_event(&mut event).await {
61 error!("Failed to process clipboard event: {}", e);
62 }
63 }
64 }
65
66 async fn process_event(&self, event: &mut ClipboardEvent) -> Result<()> {
67 match &event.content.content {
68 ClipboardContent::ImagePng { .. } | ClipboardContent::ImageJpeg { .. } => {
69 self.process_image_event(event).await?;
70 }
71 ClipboardContent::Text { data, .. } if data.len() > crate::MAX_INLINE_SIZE => {
72 self.process_large_text_event(event).await?;
73 }
74 _ => {
75 debug!("Small text content, no processing needed");
77 }
78 }
79
80 Ok(())
81 }
82
83 async fn process_image_event(&self, event: &mut ClipboardEvent) -> Result<()> {
84 info!("Processing image clipboard event");
85
86 let image_data = self.clipboard_manager.get_raw_image()?;
88
89 let format = match &event.content.content {
91 ClipboardContent::ImagePng { .. } => "png",
92 ClipboardContent::ImageJpeg { .. } => "jpeg",
93 _ => unreachable!(),
94 };
95
96 let staged = self.file_manager.stage_image(&image_data, format).await?;
97 event.staged_path = Some(staged.path.clone());
98
99 let symlink_path = self.create_symlink(&staged.path, format).await?;
101 event.symlink_path = Some(symlink_path.clone());
102
103 if self.config.enable_dual_format {
105 let path_str = symlink_path.to_string_lossy();
106
107 #[cfg(target_os = "macos")]
108 {
109 if let Err(e) = DualClipboard::set_dual_content(&path_str, &image_data) {
110 warn!("Failed to set dual clipboard format: {}", e);
111 self.set_text_clipboard(&path_str)?;
113 } else {
114 info!("Set dual clipboard: text path + original image");
115 }
116 }
117
118 #[cfg(not(target_os = "macos"))]
119 {
120 self.set_text_clipboard(&path_str)?;
122 }
123 }
124
125 self.cleanup_old_symlinks().await?;
127
128 if self.config.enable_notifications {
130 let notification_path = symlink_path.to_string_lossy();
131 self.show_notification("Image ready for Claude Code", ¬ification_path);
132 }
133
134 info!("Image processed: {}", symlink_path.display());
135 Ok(())
136 }
137
138 async fn process_large_text_event(&self, event: &mut ClipboardEvent) -> Result<()> {
139 info!("Processing large text clipboard event");
140
141 if let ClipboardContent::Text { data, .. } = &event.content.content {
142 let staged = self.file_manager.stage_text(data).await?;
144 event.staged_path = Some(staged.path.clone());
145
146 let symlink_path = self.create_symlink(&staged.path, "txt").await?;
148 event.symlink_path = Some(symlink_path.clone());
149
150 let path_str = symlink_path.to_string_lossy();
152 self.set_text_clipboard(&path_str)?;
153
154 self.cleanup_old_symlinks().await?;
156
157 info!("Large text processed: {}", symlink_path.display());
158 }
159
160 Ok(())
161 }
162
163 async fn create_symlink(&self, target: &Path, extension: &str) -> Result<PathBuf> {
164 let timestamp = Local::now().format("%Y%m%d-%H%M%S");
166 let filename = format!("{}-{}.{}", self.config.symlink_prefix, timestamp, extension);
167 let symlink_path = self.config.symlink_dir.join(&filename);
168
169 #[cfg(unix)]
171 {
172 use std::os::unix::fs::symlink;
173 symlink(target, &symlink_path)?;
174 }
175
176 #[cfg(windows)]
177 {
178 use std::os::windows::fs::symlink_file;
179 symlink_file(target, &symlink_path)?;
180 }
181
182 let latest_name = format!("{}.{}", self.config.symlink_prefix, extension);
184 let latest_path = self.config.symlink_dir.join(&latest_name);
185
186 let _ = fs::remove_file(&latest_path).await;
188
189 #[cfg(unix)]
190 {
191 use std::os::unix::fs::symlink;
192 symlink(target, &latest_path)?;
193 }
194
195 #[cfg(windows)]
196 {
197 use std::os::windows::fs::symlink_file;
198 symlink_file(target, &latest_path)?;
199 }
200
201 Ok(symlink_path)
202 }
203
204 async fn cleanup_old_symlinks(&self) -> Result<()> {
205 let pattern = format!("{}-*", self.config.symlink_prefix);
206 let mut entries = fs::read_dir(&self.config.symlink_dir).await?;
207 let mut symlinks = Vec::new();
208
209 while let Some(entry) = entries.next_entry().await? {
210 let path = entry.path();
211 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
212 if name.starts_with(&pattern) && name.contains('-') {
213 if let Ok(metadata) = entry.metadata().await {
214 if metadata.file_type().is_symlink() {
215 if let Ok(modified) = metadata.modified() {
216 symlinks.push((path, modified));
217 }
218 }
219 }
220 }
221 }
222 }
223
224 symlinks.sort_by(|a, b| b.1.cmp(&a.1));
226
227 for (path, _) in symlinks.into_iter().skip(self.config.keep_symlinks) {
229 if let Err(e) = fs::remove_file(&path).await {
230 warn!("Failed to remove old symlink: {}", e);
231 } else {
232 debug!("Removed old symlink: {}", path.display());
233 }
234 }
235
236 Ok(())
237 }
238
239 fn set_text_clipboard(&self, text: &str) -> Result<()> {
240 self.clipboard_manager.set_content(&ClipboardContent::Text {
241 data: text.to_string(),
242 truncated: None,
243 })
244 }
245
246 fn show_notification(&self, title: &str, body: &str) {
247 #[cfg(target_os = "macos")]
248 {
249 use std::process::Command;
250 let script = format!(
251 r#"display notification "{body}" with title "Claude-Utils" subtitle "{title}""#
252 );
253 let _ = Command::new("osascript").arg("-e").arg(&script).output();
254 }
255
256 #[cfg(target_os = "linux")]
257 {
258 use std::process::Command;
259 let _ = Command::new("notify-send")
260 .arg("Claude-Utils")
261 .arg(&format!("{}\n{}", title, body))
262 .output();
263 }
264
265 #[cfg(target_os = "windows")]
266 {
267 info!("Notification: {} - {}", title, body);
269 }
270 }
271}
272
273use tracing::debug;