Skip to main content

lumen_os_clipboard/
lib.rs

1//! OS clipboard host for Lumen.
2//!
3//! Wraps `arboard` 3.x behind the shared [`MimePayload`] abstraction
4//! (`QMimeData` / `GdkContentProvider` analog). Carved out of
5//! `lumen-input` per the OS plan section 469 + W6.1.
6//!
7//! - [`ClipboardHost`] - `read` / `write` / `clear` for the standard
8//!   system clipboard. Owns an `arboard::Clipboard` behind a `Mutex`.
9//! - [`set_rgba8_image`] / [`get_rgba8_image`] preserved for
10//!   backwards-compatible image round-trips used by lumenc's
11//!   `copy_image` / `save_clipboard_image` Rhai builtins.
12//!
13//! `arboard::Clipboard` is `!Send` on Linux/Wayland - store as a
14//! `NonSend` ECS resource (see [`InstallExt::install_clipboard_host`]).
15//!
16//! On `wasm32` the clipboard is the page's `navigator.clipboard`, text only.
17//! [`ClipboardHost::try_new`] finds it wherever the page has one (a secure
18//! context: `https` or `localhost`) and reports it unavailable otherwise. A
19//! page reads the clipboard asynchronously and only with the visitor's
20//! permission, so a read goes through [`ClipboardHost::read_text_then`]; the
21//! synchronous readers and images answer as an absent clipboard does.
22
23#![forbid(unsafe_code)]
24#![warn(missing_docs)]
25
26#[cfg(not(target_arch = "wasm32"))]
27use lumen_os_mime::MimeKind;
28use lumen_os_mime::MimePayload;
29#[cfg(not(target_arch = "wasm32"))]
30use std::sync::Mutex;
31
32pub use lumen_os_mime as mime;
33
34/// Single-process clipboard host. Wraps `arboard::Clipboard` behind a
35/// `Mutex` - the underlying handle is `!Send` on Linux/Wayland so this
36/// type stays `!Sync` by virtue of `Mutex<arboard::Clipboard>` having
37/// the same restriction.
38///
39/// Call it from the thread that owns the app, which is what holding it as
40/// a `NonSend` resource enforces. macOS `NSPasteboard` mutates shared
41/// state without locking it, so two threads reaching the clipboard at
42/// once corrupts memory there rather than returning an error.
43pub struct ClipboardHost {
44    #[cfg(not(target_arch = "wasm32"))]
45    inner: Mutex<arboard::Clipboard>,
46}
47
48impl ClipboardHost {
49    /// Try to initialize the OS clipboard. Returns `None` if the
50    /// backend (Wayland with no compositor, headless CI, X11 without a
51    /// running window manager) refuses.
52    #[cfg(not(target_arch = "wasm32"))]
53    pub fn try_new() -> Option<Self> {
54        arboard::Clipboard::new().ok().map(|cb| Self {
55            inner: Mutex::new(cb),
56        })
57    }
58
59    /// Find the page's clipboard. `None` outside a secure context, where
60    /// the browser does not expose one.
61    #[cfg(target_arch = "wasm32")]
62    pub fn try_new() -> Option<Self> {
63        page_clipboard().map(|_| Self {})
64    }
65
66    /// Lock the inner clipboard, recovering from a poisoned mutex.
67    ///
68    /// `arboard::Clipboard` stays valid even if a holder panicked, so
69    /// recovering the guard is safe. The previous per-site `lock().ok()`
70    /// dropped to the empty/`false` fallback on poison - permanently and
71    /// silently bricking clipboard access with no diagnostic. Here we log
72    /// once and carry on.
73    #[cfg(not(target_arch = "wasm32"))]
74    fn guard(&self) -> std::sync::MutexGuard<'_, arboard::Clipboard> {
75        self.inner.lock().unwrap_or_else(|e| {
76            eprintln!("lumen-os-clipboard: recovered poisoned clipboard lock");
77            e.into_inner()
78        })
79    }
80
81    /// Read the current clipboard contents as a multi-format
82    /// [`MimePayload`]. Tries text first, then image; returns an empty
83    /// payload when neither is available.
84    #[cfg(not(target_arch = "wasm32"))]
85    pub fn read(&self) -> MimePayload {
86        let mut payload = MimePayload::new();
87        let mut cb = self.guard();
88        if let Ok(text) = cb.get_text() {
89            payload = payload.with(MimeKind::TextPlain, text.into_bytes());
90        }
91        // arboard's `get_image` is RGBA8 raw; we surface it under a
92        // synthetic raw-rgba MIME (PNG encoding is a lumenc-layer
93        // concern - see `handle_save_clipboard_image`).
94        if let Ok(img) = cb.get_image() {
95            let bytes = img.bytes.into_owned();
96            let header = format!("{}x{}:", img.width, img.height);
97            let mut combined = header.into_bytes();
98            combined.extend_from_slice(&bytes);
99            payload = payload.with(
100                MimeKind::Custom(std::sync::Arc::from("application/x-lumen-rgba8")),
101                combined,
102            );
103        }
104        payload
105    }
106
107    /// Read the current clipboard contents - no backend on wasm32.
108    #[cfg(target_arch = "wasm32")]
109    pub fn read(&self) -> MimePayload {
110        MimePayload::new()
111    }
112
113    /// Write a [`MimePayload`] onto the system clipboard. Picks the
114    /// first MIME kind arboard understands (text/plain -> `set_text`).
115    /// Returns `true` on success.
116    #[cfg(not(target_arch = "wasm32"))]
117    pub fn write(&self, payload: &MimePayload) -> bool {
118        let mut cb = self.guard();
119        // Prefer text/plain; arboard's API only exposes text + image.
120        if let Some(bytes) = payload.get(&MimeKind::TextPlain) {
121            let text = String::from_utf8_lossy(bytes).into_owned();
122            return cb.set_text(text).is_ok();
123        }
124        // No directly-supported MIME - caller must use
125        // `set_rgba8_image` for image payloads since the encoded
126        // representation (PNG) lives outside this crate.
127        false
128    }
129
130    /// Write a [`MimePayload`] onto the page's clipboard. Text only: a
131    /// payload with no `text/plain` entry writes nothing.
132    #[cfg(target_arch = "wasm32")]
133    pub fn write(&self, payload: &MimePayload) -> bool {
134        match payload.text() {
135            Some(text) => self.write_text(&text),
136            None => false,
137        }
138    }
139
140    /// Convenience: write a plain-text payload. Same as `write` with a
141    /// `MimePayload::from(&str)` but avoids the allocation when callers
142    /// only have a `&str` (the text editor's copy / cut path).
143    #[cfg(not(target_arch = "wasm32"))]
144    pub fn write_text(&self, text: &str) -> bool {
145        let mut cb = self.guard();
146        cb.set_text(text.to_string()).is_ok()
147    }
148
149    /// Convenience: write a plain-text payload through the page's
150    /// `navigator.clipboard.writeText`.
151    ///
152    /// The browser settles the write after this returns, so `true` means the
153    /// page accepted the request. A write the browser refuses later (the
154    /// document lost focus, the visitor denied the permission) is reported
155    /// to the page's console.
156    #[cfg(target_arch = "wasm32")]
157    pub fn write_text(&self, text: &str) -> bool {
158        let Some(clipboard) = page_clipboard() else {
159            return false;
160        };
161        let written = wasm_bindgen_futures::JsFuture::from(clipboard.write_text(text));
162        wasm_bindgen_futures::spawn_local(async move {
163            if let Err(error) = written.await {
164                web_sys::console::warn_2(
165                    &"clipboard_write: the browser refused the text:".into(),
166                    &error,
167                );
168            }
169        });
170        true
171    }
172
173    /// Convenience: read the current clipboard text. Returns an empty
174    /// string when no text payload is available.
175    #[cfg(not(target_arch = "wasm32"))]
176    pub fn read_text(&self) -> String {
177        let mut cb = self.guard();
178        cb.get_text().unwrap_or_default()
179    }
180
181    /// Convenience: read the current clipboard text - a page cannot read it
182    /// synchronously, so this is always empty on wasm32. Use
183    /// [`read_text_then`](Self::read_text_then).
184    #[cfg(target_arch = "wasm32")]
185    pub fn read_text(&self) -> String {
186        String::new()
187    }
188
189    /// Read the clipboard text and hand it to `done`, which is called
190    /// exactly once. Empty when the clipboard holds no text.
191    ///
192    /// On the desktop the read is synchronous and `done` runs before this
193    /// returns.
194    #[cfg(not(target_arch = "wasm32"))]
195    pub fn read_text_then(&self, done: impl FnOnce(String) + 'static) {
196        done(self.read_text());
197    }
198
199    /// Read the clipboard text and hand it to `done`, which is called
200    /// exactly once. Empty when the clipboard holds no text.
201    ///
202    /// In a page `done` runs when `navigator.clipboard.readText` settles,
203    /// after this returns. The browser asks the visitor first; a read they
204    /// refuse, or one the browser blocks, answers with empty text.
205    #[cfg(target_arch = "wasm32")]
206    pub fn read_text_then(&self, done: impl FnOnce(String) + 'static) {
207        let Some(clipboard) = page_clipboard() else {
208            done(String::new());
209            return;
210        };
211        let read = wasm_bindgen_futures::JsFuture::from(clipboard.read_text());
212        wasm_bindgen_futures::spawn_local(async move {
213            let text = match read.await {
214                Ok(text) => text.as_string().unwrap_or_default(),
215                Err(error) => {
216                    web_sys::console::warn_2(
217                        &"clipboard_read: the browser refused the read:".into(),
218                        &error,
219                    );
220                    String::new()
221                }
222            };
223            done(text);
224        });
225    }
226
227    /// Clear the clipboard. Returns `true` on success.
228    #[cfg(not(target_arch = "wasm32"))]
229    pub fn clear(&self) -> bool {
230        let mut cb = self.guard();
231        cb.clear().is_ok()
232    }
233
234    /// Clear the clipboard - no backend on wasm32.
235    #[cfg(target_arch = "wasm32")]
236    pub fn clear(&self) -> bool {
237        false
238    }
239
240    /// Write the supplied RGBA8 image (`width x height x 4` bytes) to
241    /// the system clipboard. Backs the `copy_image` script builtin.
242    #[cfg(not(target_arch = "wasm32"))]
243    pub fn set_rgba8_image(&self, width: u32, height: u32, rgba: Vec<u8>) -> bool {
244        let img = arboard::ImageData {
245            width: width as usize,
246            height: height as usize,
247            bytes: std::borrow::Cow::Owned(rgba),
248        };
249        self.guard().set_image(img).is_ok()
250    }
251
252    /// Write an RGBA8 image to the system clipboard - no backend on wasm32.
253    #[cfg(target_arch = "wasm32")]
254    pub fn set_rgba8_image(&self, _width: u32, _height: u32, _rgba: Vec<u8>) -> bool {
255        false
256    }
257
258    /// Read the current clipboard image as RGBA8. Returns
259    /// `(width, height, rgba_bytes)` when an image is present.
260    #[cfg(not(target_arch = "wasm32"))]
261    pub fn get_rgba8_image(&self) -> Option<(u32, u32, Vec<u8>)> {
262        let mut cb = self.guard();
263        let img = cb.get_image().ok()?;
264        Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
265    }
266
267    /// Read the current clipboard image as RGBA8 - no backend on wasm32.
268    #[cfg(target_arch = "wasm32")]
269    pub fn get_rgba8_image(&self) -> Option<(u32, u32, Vec<u8>)> {
270        None
271    }
272}
273
274/// The page's `navigator.clipboard`, where the browser exposes one. It is
275/// `undefined` outside a secure context, which the typed getter does not say.
276#[cfg(target_arch = "wasm32")]
277fn page_clipboard() -> Option<web_sys::Clipboard> {
278    let clipboard = web_sys::window()?.navigator().clipboard();
279    let value: &wasm_bindgen::JsValue = clipboard.as_ref();
280    (!value.is_undefined() && !value.is_null()).then_some(clipboard)
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use lumen_os_mime::MimeKind;
287
288    // We can't unit-test against the real arboard backend in CI (no
289    // display server). Cover the MIME round-trip helpers instead.
290
291    #[test]
292    fn payload_from_str_carries_text() {
293        let p: MimePayload = "abc".into();
294        assert_eq!(p.text().as_deref(), Some("abc"));
295    }
296
297    #[test]
298    fn payload_write_picks_textplain() {
299        // ClipboardHost::write is documented to short-circuit on
300        // `TextPlain`; verify it returns false on a payload that
301        // carries only octet-stream bytes (no text/plain -> nothing
302        // arboard can send).
303        let p: MimePayload = vec![0u8, 1, 2].into();
304        assert!(!p.has(&MimeKind::TextPlain));
305    }
306}