1use fission_core::env::Clipboard;
2use fission_core::{
3 collect_data_stream, single_chunk_data_stream, Bytes, ClipboardContent, ClipboardError,
4 ClipboardText, ClipboardWriteTextRequest, CLEAR_CLIPBOARD, READ_CLIPBOARD_CONTENT,
5 READ_CLIPBOARD_TEXT, WRITE_CLIPBOARD_CONTENT, WRITE_CLIPBOARD_TEXT,
6};
7use fission_shell::async_host::AsyncRegistry;
8#[cfg(target_os = "ios")]
9use objc::{class, msg_send, sel, sel_impl};
10#[cfg(target_os = "ios")]
11use std::ffi::CStr;
12#[cfg(target_os = "ios")]
13use std::os::raw::{c_char, c_void};
14use std::sync::{Arc, Mutex};
15
16#[derive(Clone, Debug, Default)]
17pub struct ClipboardHostItem {
18 pub content_type: String,
19 pub bytes: Bytes,
20 pub suggested_name: Option<String>,
21}
22
23#[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))]
24use arboard::Clipboard as Arboard;
25
26#[cfg(target_os = "ios")]
27#[link(name = "UIKit", kind = "framework")]
28extern "C" {}
29
30pub struct DesktopClipboard {
31 #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))]
32 system: Arc<Mutex<Option<Arboard>>>,
33 memory: Arc<Mutex<String>>,
34}
35
36impl DesktopClipboard {
37 pub fn new() -> Self {
38 Self {
39 #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))]
40 system: Arc::new(Mutex::new(Arboard::new().ok())),
41 memory: Arc::new(Mutex::new(String::new())),
42 }
43 }
44}
45
46impl Clipboard for DesktopClipboard {
47 fn get_text(&self) -> Option<String> {
48 #[cfg(target_os = "ios")]
49 if let Some(text) = ios_clipboard_text() {
50 return Some(text);
51 }
52
53 #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))]
54 if let Ok(mut lock) = self.system.lock() {
55 if let Some(cb) = lock.as_mut() {
56 if let Ok(text) = cb.get_text() {
57 return Some(text);
58 }
59 }
60 }
61 self.memory.lock().ok().map(|text| text.clone())
62 }
63
64 fn set_text(&self, text: &str) {
65 if let Ok(mut memory) = self.memory.lock() {
66 *memory = text.to_string();
67 }
68 #[cfg(target_os = "ios")]
69 ios_set_clipboard_text(text);
70
71 #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))]
72 if let Ok(mut lock) = self.system.lock() {
73 if let Some(cb) = lock.as_mut() {
74 let _ = cb.set_text(text);
75 }
76 }
77 }
78}
79
80#[cfg(target_os = "ios")]
81fn ios_clipboard_text() -> Option<String> {
82 unsafe {
83 let pasteboard: *mut objc::runtime::Object =
84 msg_send![class!(UIPasteboard), generalPasteboard];
85 if pasteboard.is_null() {
86 return None;
87 }
88 let string: *mut objc::runtime::Object = msg_send![pasteboard, string];
89 if string.is_null() {
90 return None;
91 }
92 let c_string: *const c_char = msg_send![string, UTF8String];
93 if c_string.is_null() {
94 return None;
95 }
96 CStr::from_ptr(c_string)
97 .to_str()
98 .ok()
99 .map(ToOwned::to_owned)
100 }
101}
102
103#[cfg(target_os = "ios")]
104fn ios_set_clipboard_text(text: &str) {
105 unsafe {
106 let pasteboard: *mut objc::runtime::Object =
107 msg_send![class!(UIPasteboard), generalPasteboard];
108 if pasteboard.is_null() {
109 return;
110 }
111 let string: *mut objc::runtime::Object = msg_send![class!(NSString), alloc];
112 let string: *mut objc::runtime::Object = msg_send![
113 string,
114 initWithBytes: text.as_ptr() as *const c_void
115 length: text.len()
116 encoding: 4usize
117 ];
118 let _: () = msg_send![pasteboard, setString: string];
119 }
120}
121
122pub trait ClipboardHost: Send + Sync + 'static {
124 fn read_text(&self) -> Result<ClipboardText, ClipboardError>;
126 fn write_text(&self, request: ClipboardWriteTextRequest) -> Result<(), ClipboardError>;
128 fn read_content(&self) -> Result<Vec<ClipboardHostItem>, ClipboardError>;
130 fn write_content(&self, request: Vec<ClipboardHostItem>) -> Result<(), ClipboardError>;
132 fn clear(&self) -> Result<(), ClipboardError>;
134}
135
136impl ClipboardHost for DesktopClipboard {
137 fn read_text(&self) -> Result<ClipboardText, ClipboardError> {
138 Ok(ClipboardText {
139 text: self.get_text(),
140 })
141 }
142
143 fn write_text(&self, request: ClipboardWriteTextRequest) -> Result<(), ClipboardError> {
144 self.set_text(&request.text);
145 Ok(())
146 }
147
148 fn read_content(&self) -> Result<Vec<ClipboardHostItem>, ClipboardError> {
149 let text = self.get_text().unwrap_or_default();
150 Ok(if text.is_empty() {
151 Vec::new()
152 } else {
153 vec![ClipboardHostItem {
154 content_type: "text/plain".into(),
155 bytes: Bytes::from(text),
156 suggested_name: None,
157 }]
158 })
159 }
160
161 fn write_content(&self, request: Vec<ClipboardHostItem>) -> Result<(), ClipboardError> {
162 if let Some(item) = request
163 .iter()
164 .find(|item| item.content_type.starts_with("text/plain"))
165 {
166 if let Ok(text) = String::from_utf8(item.bytes.to_vec()) {
167 self.set_text(&text);
168 return Ok(());
169 }
170 }
171 Err(ClipboardError::unsupported("write_content_non_text"))
172 }
173
174 fn clear(&self) -> Result<(), ClipboardError> {
175 self.set_text("");
176 Ok(())
177 }
178}
179
180#[derive(Debug, Default)]
182pub struct MemoryClipboardHost {
183 content: Arc<Mutex<Vec<ClipboardHostItem>>>,
184}
185
186impl ClipboardHost for MemoryClipboardHost {
187 fn read_text(&self) -> Result<ClipboardText, ClipboardError> {
188 let content = self.content.lock().map_err(|_| {
189 ClipboardError::new("lock_poisoned", "memory clipboard lock was poisoned")
190 })?;
191 let text = content
192 .iter()
193 .find(|item| item.content_type.starts_with("text/plain"))
194 .and_then(|item| String::from_utf8(item.bytes.to_vec()).ok());
195 Ok(ClipboardText { text })
196 }
197
198 fn write_text(&self, request: ClipboardWriteTextRequest) -> Result<(), ClipboardError> {
199 let mut content = self.content.lock().map_err(|_| {
200 ClipboardError::new("lock_poisoned", "memory clipboard lock was poisoned")
201 })?;
202 *content = vec![ClipboardHostItem {
203 content_type: "text/plain".into(),
204 bytes: Bytes::from(request.text),
205 suggested_name: None,
206 }];
207 Ok(())
208 }
209
210 fn read_content(&self) -> Result<Vec<ClipboardHostItem>, ClipboardError> {
211 self.content
212 .lock()
213 .map(|content| content.clone())
214 .map_err(|_| ClipboardError::new("lock_poisoned", "memory clipboard lock was poisoned"))
215 }
216
217 fn write_content(&self, request: Vec<ClipboardHostItem>) -> Result<(), ClipboardError> {
218 let mut content = self.content.lock().map_err(|_| {
219 ClipboardError::new("lock_poisoned", "memory clipboard lock was poisoned")
220 })?;
221 *content = request;
222 Ok(())
223 }
224
225 fn clear(&self) -> Result<(), ClipboardError> {
226 let mut content = self.content.lock().map_err(|_| {
227 ClipboardError::new("lock_poisoned", "memory clipboard lock was poisoned")
228 })?;
229 content.clear();
230 Ok(())
231 }
232}
233
234pub(crate) fn register_clipboard_capabilities(
235 async_registry: &mut AsyncRegistry,
236 host: Arc<dyn ClipboardHost>,
237) {
238 let read_text_host = host.clone();
239 async_registry.register_operation_capability(READ_CLIPBOARD_TEXT, move |(), _| {
240 let host = read_text_host.clone();
241 async move { host.read_text() }
242 });
243
244 let write_text_host = host.clone();
245 async_registry.register_operation_capability(WRITE_CLIPBOARD_TEXT, move |request, _| {
246 let host = write_text_host.clone();
247 async move { host.write_text(request) }
248 });
249
250 let read_content_host = host.clone();
251 async_registry.register_operation_capability(READ_CLIPBOARD_CONTENT, move |(), ctx| {
252 let host = read_content_host.clone();
253 async move {
254 let items = host.read_content()?;
255 Ok(ClipboardContent {
256 items: items
257 .into_iter()
258 .map(|item| {
259 let byte_len = item.bytes.len() as u64;
260 let stream = ctx.register_data_stream(single_chunk_data_stream(item.bytes));
261 fission_core::ClipboardItem {
262 content_type: item.content_type,
263 stream,
264 byte_len: Some(byte_len),
265 suggested_name: item.suggested_name,
266 }
267 })
268 .collect(),
269 })
270 }
271 });
272
273 let write_content_host = host.clone();
274 async_registry.register_operation_capability(WRITE_CLIPBOARD_CONTENT, move |request, ctx| {
275 let host = write_content_host.clone();
276 async move {
277 let mut items = Vec::with_capacity(request.items.len());
278 for item in request.items {
279 let stream = ctx.open_data_stream(item.stream).map_err(|error| {
280 ClipboardError::new("stream_open_failed", error.to_string())
281 })?;
282 let bytes = collect_data_stream(stream).await.map_err(|error| {
283 ClipboardError::new("stream_read_failed", error.to_string())
284 })?;
285 items.push(ClipboardHostItem {
286 content_type: item.content_type,
287 bytes,
288 suggested_name: item.suggested_name,
289 });
290 }
291 host.write_content(items)
292 }
293 });
294
295 async_registry.register_operation_capability(CLEAR_CLIPBOARD, move |(), _| {
296 let host = host.clone();
297 async move { host.clear() }
298 });
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn memory_clipboard_reads_and_writes_text() {
307 let host = MemoryClipboardHost::default();
308 host.write_text(ClipboardWriteTextRequest {
309 text: "copied".into(),
310 })
311 .unwrap();
312 assert_eq!(host.read_text().unwrap().text.as_deref(), Some("copied"));
313 host.clear().unwrap();
314 assert_eq!(host.read_text().unwrap().text, None);
315 }
316
317 #[test]
318 fn desktop_clipboard_host_supports_text_content() {
319 let host = DesktopClipboard::new();
320 host.write_text(ClipboardWriteTextRequest {
321 text: "copied".into(),
322 })
323 .unwrap();
324 let content = ClipboardHost::read_content(&host).unwrap();
325 assert_eq!(content[0].content_type, "text/plain");
326 }
327}