Skip to main content

fission_shell_winit/
clipboard.rs

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
122/// Host-side clipboard provider used by shell capability registration.
123pub trait ClipboardHost: Send + Sync + 'static {
124    /// Reads plain text from the host clipboard.
125    fn read_text(&self) -> Result<ClipboardText, ClipboardError>;
126    /// Writes plain text to the host clipboard.
127    fn write_text(&self, request: ClipboardWriteTextRequest) -> Result<(), ClipboardError>;
128    /// Reads typed clipboard items from the host clipboard.
129    fn read_content(&self) -> Result<Vec<ClipboardHostItem>, ClipboardError>;
130    /// Writes typed clipboard items to the host clipboard.
131    fn write_content(&self, request: Vec<ClipboardHostItem>) -> Result<(), ClipboardError>;
132    /// Clears clipboard content when the host allows apps to do that.
133    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/// In-process clipboard host for tests and non-OS environments.
181#[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}