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}