claude_utils/clipboard/
mod.rs1pub 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>, #[serde(skip_serializing_if = "Option::is_none")]
25 file: Option<String>, 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 if let Ok(image_data) = clipboard.get_image() {
77 return self.process_image(image_data);
78 }
79
80 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 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 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 (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 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}