Skip to main content

claude_utils/clipboard/
mod.rs

1pub mod processor;
2pub mod watcher;
3
4use arboard::{Clipboard as Arboard, ImageData};
5use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
6use serde::{Deserialize, Serialize};
7use std::sync::{Arc, Mutex};
8
9use crate::{ClaudeUtilsError, Result};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(tag = "type")]
13pub enum ClipboardContent {
14    #[serde(rename = "text/plain")]
15    Text {
16        data: String,
17        #[serde(skip_serializing_if = "Option::is_none")]
18        truncated: Option<bool>,
19    },
20    #[serde(rename = "image/png")]
21    ImagePng {
22        #[serde(skip_serializing_if = "Option::is_none")]
23        data: Option<String>, // base64 encoded if small
24        #[serde(skip_serializing_if = "Option::is_none")]
25        file: Option<String>, // file path if large
26        width: usize,
27        height: usize,
28        size: usize,
29    },
30    #[serde(rename = "image/jpeg")]
31    ImageJpeg {
32        #[serde(skip_serializing_if = "Option::is_none")]
33        data: Option<String>,
34        #[serde(skip_serializing_if = "Option::is_none")]
35        file: Option<String>,
36        width: usize,
37        height: usize,
38        size: usize,
39    },
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ClipboardMetadata {
44    pub timestamp: chrono::DateTime<chrono::Utc>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub source: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ClipboardData {
51    #[serde(flatten)]
52    pub content: ClipboardContent,
53    pub metadata: ClipboardMetadata,
54}
55
56pub struct ClipboardManager {
57    clipboard: Arc<Mutex<Arboard>>,
58}
59
60impl ClipboardManager {
61    pub fn new() -> Result<Self> {
62        let clipboard = Arboard::new().map_err(|e| ClaudeUtilsError::Clipboard(e.to_string()))?;
63
64        Ok(Self {
65            clipboard: Arc::new(Mutex::new(clipboard)),
66        })
67    }
68
69    pub fn get_content(&self) -> Result<ClipboardData> {
70        let mut clipboard = self
71            .clipboard
72            .lock()
73            .map_err(|e| ClaudeUtilsError::Clipboard(format!("Lock error: {e}")))?;
74
75        // Try to get image first (more specific)
76        if let Ok(image_data) = clipboard.get_image() {
77            return self.process_image(image_data);
78        }
79
80        // Fall back to text
81        if let Ok(text) = clipboard.get_text() {
82            return Ok(self.process_text(text));
83        }
84
85        Err(ClaudeUtilsError::Clipboard(
86            "No content in clipboard".to_string(),
87        ))
88    }
89
90    pub fn set_content(&self, content: &ClipboardContent) -> Result<()> {
91        let mut clipboard = self
92            .clipboard
93            .lock()
94            .map_err(|e| ClaudeUtilsError::Clipboard(format!("Lock error: {e}")))?;
95
96        match content {
97            ClipboardContent::Text { data, .. } => {
98                clipboard
99                    .set_text(data)
100                    .map_err(|e| ClaudeUtilsError::Clipboard(e.to_string()))?;
101            }
102            ClipboardContent::ImagePng {
103                data: Some(base64_data),
104                width,
105                height,
106                ..
107            }
108            | ClipboardContent::ImageJpeg {
109                data: Some(base64_data),
110                width,
111                height,
112                ..
113            } => {
114                let bytes = BASE64.decode(base64_data).map_err(|e| {
115                    ClaudeUtilsError::Clipboard(format!("Base64 decode error: {e}"))
116                })?;
117
118                let image_data = ImageData {
119                    width: *width,
120                    height: *height,
121                    bytes: bytes.into(),
122                };
123
124                clipboard
125                    .set_image(image_data)
126                    .map_err(|e| ClaudeUtilsError::Clipboard(e.to_string()))?;
127            }
128            _ => {
129                return Err(ClaudeUtilsError::Clipboard(
130                    "Cannot set clipboard from file reference".to_string(),
131                ));
132            }
133        }
134
135        Ok(())
136    }
137
138    fn process_text(&self, text: String) -> ClipboardData {
139        let truncated = text.len() > crate::MAX_INLINE_SIZE;
140        let data = if truncated {
141            text.chars().take(crate::MAX_INLINE_SIZE).collect()
142        } else {
143            text.clone()
144        };
145
146        ClipboardData {
147            content: ClipboardContent::Text {
148                data,
149                truncated: if truncated { Some(true) } else { None },
150            },
151            metadata: ClipboardMetadata {
152                timestamp: chrono::Utc::now(),
153                source: None,
154            },
155        }
156    }
157
158    fn process_image(&self, image_data: ImageData<'_>) -> Result<ClipboardData> {
159        use image::{ImageFormat, RgbaImage};
160
161        // Convert arboard image data to image crate format
162        let img = RgbaImage::from_raw(
163            image_data.width as u32,
164            image_data.height as u32,
165            image_data.bytes.to_vec(),
166        )
167        .ok_or_else(|| {
168            ClaudeUtilsError::ImageProcessing(image::ImageError::Limits(
169                image::error::LimitError::from_kind(image::error::LimitErrorKind::DimensionError),
170            ))
171        })?;
172
173        // Detect format and encode
174        let mut png_bytes = Vec::new();
175        img.write_to(&mut std::io::Cursor::new(&mut png_bytes), ImageFormat::Png)?;
176
177        let size = png_bytes.len();
178        let (data, file) = if size <= crate::MAX_INLINE_SIZE {
179            (Some(BASE64.encode(&png_bytes)), None)
180        } else {
181            // Will be handled by file manager
182            (None, None)
183        };
184
185        Ok(ClipboardData {
186            content: ClipboardContent::ImagePng {
187                data,
188                file,
189                width: image_data.width,
190                height: image_data.height,
191                size,
192            },
193            metadata: ClipboardMetadata {
194                timestamp: chrono::Utc::now(),
195                source: None,
196            },
197        })
198    }
199
200    pub fn get_raw_image(&self) -> Result<Vec<u8>> {
201        let mut clipboard = self
202            .clipboard
203            .lock()
204            .map_err(|e| ClaudeUtilsError::Clipboard(format!("Lock error: {e}")))?;
205
206        let image_data = clipboard
207            .get_image()
208            .map_err(|e| ClaudeUtilsError::Clipboard(e.to_string()))?;
209
210        // Convert to PNG
211        let img = image::RgbaImage::from_raw(
212            image_data.width as u32,
213            image_data.height as u32,
214            image_data.bytes.to_vec(),
215        )
216        .ok_or_else(|| {
217            ClaudeUtilsError::ImageProcessing(image::ImageError::Limits(
218                image::error::LimitError::from_kind(image::error::LimitErrorKind::DimensionError),
219            ))
220        })?;
221
222        let mut png_bytes = Vec::new();
223        img.write_to(
224            &mut std::io::Cursor::new(&mut png_bytes),
225            image::ImageFormat::Png,
226        )?;
227
228        Ok(png_bytes)
229    }
230}