Skip to main content

fission_shell_winit/
clipboard.rs

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