git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit17641bfe98f92e38e6cb1d89b6597518236b1041
parentbc2ff30d81
authorClaude <noreply@anthropic.com>
date2026-10-05 18:34
Clipboard on macOS and in the browser

widget::clipboard stays one synchronous text pair (copy_to_clipboard /
read_from_clipboard) for every widget, now with a backend per platform:

- Wayland: wl-copy / wl-paste (xclip), moved verbatim.
- macOS: the general NSPasteboard's plain-text type.
- A page: a browser hands a page the clipboard only inside a paste event,
  so the canvas lets Ctrl/Cmd+C, X and V keep their defaults
  (dom::clipboard_key) and holds a Ctrl/Cmd+V from the app until its paste
  event has handed over the text, or until a zero timer if none comes; a
  release never overtakes the held press. A copy writes through
  navigator.clipboard.writeText where the page has it (checked first:
  calling into undefined throws through the wasm frames), and the
  copy / cut event the key raises carries it too, which needs no secure
  context. Before this a copy in a page panicked (std::thread::spawn).

scripts/web-probe/clipboard checks it in headless Chromium through the
demo's text box, each result read back from the system clipboard: copy,
paste, copy with writeText refused, and paste with the paste event
swallowed. All four pass. macOS is type-checked (check-mac) and has not
run. Linux: lib suite as before (the 2 known failures); check-wasm ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ

 CLAUDE.md                       |  24 ++++-
 Cargo.toml                      |   5 +-
 scripts/web-probe/clipboard     |  21 +++++
 scripts/web-probe/clipboard.mjs |  72 +++++++++++++++
 src/backend/dom.rs              |  36 ++++++++
 src/web/shell.rs                | 104 +++++++++++++++++++--
 src/widget/core.rs              | 198 +++++++++++++++++++++++++++++++---------
 7 files changed, 404 insertions(+), 56 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a68c2c1..23222a7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -117,8 +117,23 @@ in its own config.
   first face with the glyph.
 - **`web::capture().await`**: the next frame, read back from the GPU. Headless Chromium
   composites in software and leaves a WebGPU canvas out of its screenshots and `toDataURL`.
-
-Not there yet: the clipboard, IME composition, drag and drop, file dialogs, and an app whose
+- **The clipboard** (since 2026-10-05): `widget::clipboard` is one synchronous text pair
+  (`copy_to_clipboard` / `read_from_clipboard`, every widget's copy, cut and paste) with a
+  backend per platform — `wl-copy` / `wl-paste` (`xclip`) on Wayland, `NSPasteboard` on
+  macOS, and in a page the page's own clipboard events, because a page may read the
+  clipboard only inside a `paste` event. So the canvas lets ⌘/Ctrl+C, X and V keep their
+  defaults (`dom::clipboard_key`), and a ⌘/Ctrl+V is HELD from the app until its `paste`
+  event has handed over the text (then a read answers it) — or, if none comes, until a
+  zero timer, when a read answers the page's own last copy or paste; a release never
+  overtakes it. A copy writes through `navigator.clipboard.writeText` where the page has it
+  (a secure context; checked first, since calling into undefined throws through the wasm
+  frames), and the `copy` / `cut` event the key raises carries it too, which needs no
+  secure context. Until then a copy in a page panicked (`std::thread::spawn`).
+  `scripts/web-probe/clipboard` is the check: copy, paste, copy with `writeText` refused,
+  and paste with the `paste` event swallowed, each read back from the system clipboard —
+  all four pass in headless Chromium (2026-10-05).
+
+Not there yet: IME composition, drag and drop, file dialogs, and an app whose
 text is not the display list's (`display_list_text` false — it stages its own through the
 native-only `stage_renderer`, so draws no text here).
 
@@ -260,8 +275,11 @@ change. What the shell does, in AppKit's terms (module doc in `src/mac/mod.rs`):
   compositor's close does; the run loop is stopped once it has.
 - **Fonts**: the system set is always loaded on macOS (`build_font_system`) — it is what
   cosmic-text's macOS fallback list names.
+- **Clipboard**: the general `NSPasteboard`'s plain-text type, behind the same
+  `widget::clipboard` pair every widget uses; ⌘C / ⌘X / ⌘V reach the widgets as Ctrl+C /
+  X / V do on Linux, since Command reads as `ctrl`.
 
-Not there yet: the clipboard, IME (`NSTextInputClient`), drag and drop, the context menu
+Not there yet: IME (`NSTextInputClient`), drag and drop, the context menu
 in a popup window (it is drawn in the window, as on a layer surface), blur behind the window,
 a menu bar beyond Quit. **None of it has run**: this is Linux, where an Apple target can be
 type-checked but not linked. `scripts/check-mac` type-checks the library, the demo, every
diff --git a/Cargo.toml b/Cargo.toml
index 6de8039..ec7c42c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -77,7 +77,7 @@ xkeysym = "0.2"
 [target.'cfg(target_os = "macos")'.dependencies]
 objc2 = "0.6"
 objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSGeometry", "NSNotification", "NSObject", "NSDate", "NSDictionary"] }
-objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSWindow", "NSView", "NSResponder", "NSEvent", "NSScreen", "NSCursor", "NSMenu", "NSMenuItem", "NSColor", "NSTrackingArea", "NSGraphics", "NSGraphicsContext", "NSRunningApplication", "objc2-quartz-core", "objc2-core-foundation"] }
+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSWindow", "NSView", "NSResponder", "NSEvent", "NSScreen", "NSCursor", "NSMenu", "NSMenuItem", "NSPasteboard", "NSColor", "NSTrackingArea", "NSGraphics", "NSGraphicsContext", "NSRunningApplication", "objc2-quartz-core", "objc2-core-foundation"] }
 objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "CALayer", "CAMetalLayer", "objc2-core-foundation"] }
 dispatch2 = "0.3"
 
@@ -91,6 +91,9 @@ web-sys = { version = "0.3", features = [
     "console",
     "Window",
     "Navigator",
+    "Clipboard",
+    "ClipboardEvent",
+    "DataTransfer",
     "Document",
     "Element",
     "HtmlCanvasElement",
diff --git a/scripts/web-probe/clipboard b/scripts/web-probe/clipboard
new file mode 100755
index 0000000..8a55249
--- /dev/null
+++ b/scripts/web-probe/clipboard
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# web-probe/clipboard — the clipboard through the browser shell: the
+# reference app (src/main.rs) built for the page as `demo` builds it, and
+# clipboard.mjs's copy / paste round trips through its text box, in headless
+# Chromium with the clipboard permissions granted. Prints one line a check
+# and fails if any does. Needs what `run` needs.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+fonts=${DEMO_FONTS_DIR:-/usr/share/fonts}
+cd "$here/../.."
+
+cargo build --release --target wasm32-unknown-unknown --example demo_web
+site=$(mktemp -d)
+trap 'rm -rf "$site"' EXIT
+wasm-bindgen --target web --no-typescript --out-dir "$site" \
+    target/wasm32-unknown-unknown/release/examples/demo_web.wasm
+cp "$here/demo.html" "$site/demo.html"
+ln -s "$fonts" "$site/fonts"
+(cd "$fonts" && find . \( -type f -o -type l \) \( -iname '*.ttf' -o -iname '*.otf' -o -iname '*.ttc' -o -iname '*.otc' \) \
+    | sed 's|^\./||' | DEMO_FAMILIES="${DEMO_FAMILIES:-}" python3 -c 'import json,os,sys; print(json.dumps({"files": sys.stdin.read().split(), "families": os.environ["DEMO_FAMILIES"]}))') > "$site/fonts.json"
+node "$here/clipboard.mjs" "$site"
diff --git a/scripts/web-probe/clipboard.mjs b/scripts/web-probe/clipboard.mjs
new file mode 100644
index 0000000..3605e48
--- /dev/null
+++ b/scripts/web-probe/clipboard.mjs
@@ -0,0 +1,72 @@
+// clipboard.mjs <site-dir>: the clipboard through the browser shell, in the
+// demo's text box (src/main.rs on demo_web). Four checks, each read back
+// from the system clipboard:
+//   1. copy — Ctrl+A, Ctrl+C — puts the box's text there;
+//   2. paste — Ctrl+V with other text there — takes it (copied back out);
+//   3. copy with the page's writeText refused: the `copy` event alone
+//      carries it (what an insecure context has);
+//   4. paste with the `paste` event swallowed: the held key goes through on
+//      its timer and pastes the page's own last copy.
+import { open } from './browser.mjs';
+
+const [root] = process.argv.slice(2);
+if (!root) { console.error('usage: clipboard.mjs <site-dir>'); process.exit(2); }
+let ok = true;
+const check = (what, got, want) => {
+  const pass = got === want;
+  ok &&= pass;
+  console.log(`${pass ? 'ok  ' : 'FAIL'} ${what}: ${JSON.stringify(got)}${pass ? '' : ` (want ${JSON.stringify(want)})`}`);
+};
+
+async function session(body) {
+  const { page, logs, close } = await open(root, 'demo.html', 1280, 800);
+  await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
+  const m = page.mouse, k = page.keyboard, wait = ms => page.waitForTimeout(ms);
+  const t = {
+    page, wait,
+    read: () => page.evaluate(() => navigator.clipboard.readText()),
+    write: s => page.evaluate(s => (window.realWrite || (t => navigator.clipboard.writeText(t)))(s), s),
+    chord: async key => { await k.down('Control'); await k.press(key); await k.up('Control'); await wait(400); },
+    focusBox: async () => { await m.move(640, 168); await m.down(); await m.up(); await wait(300); },
+    type: async s => { await k.type(s); await wait(300); },
+    key: async s => { await k.press(s); await wait(100); },
+  };
+  try {
+    await m.move(1279, 799);
+    await page.waitForFunction(() => window.demoReady === true, null, { timeout: 120000 });
+    await wait(3000);
+    await body(t);
+  } catch (e) { ok = false; logs.push('[clipboard] ' + String(e)); }
+  for (const l of logs) if (!l.includes('WebGPU is experimental')) console.log(l);
+  await close();
+}
+
+await session(async t => {
+  await t.write('before');
+  await t.focusBox(); await t.type('hello web');
+  await t.chord('a'); await t.chord('c');
+  check('copy', await t.read(), 'hello web');
+  await t.write('from the system');
+  await t.chord('a'); await t.chord('v');
+  await t.chord('a'); await t.chord('c');
+  check('paste', await t.read(), 'from the system');
+});
+
+await session(async t => {
+  await t.write('before');
+  await t.page.evaluate(() => {
+    const real = Clipboard.prototype.writeText;
+    window.realWrite = s => real.call(navigator.clipboard, s);
+    Clipboard.prototype.writeText = () => Promise.reject(new Error('refused'));
+  });
+  await t.focusBox(); await t.type('via the event');
+  await t.chord('a'); await t.chord('c');
+  check('copy, the copy event alone', await t.read(), 'via the event');
+  await t.page.evaluate(() => window.addEventListener('paste', e => e.stopImmediatePropagation(), true));
+  await t.write('system text the page never sees');
+  await t.focusBox(); await t.key('End'); await t.chord('v');
+  await t.chord('a'); await t.chord('c');
+  check('paste, no paste event', await t.read(), 'via the eventvia the event');
+});
+
+process.exit(ok ? 0 : 1);
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index e0a3d10..9c50048 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -63,6 +63,30 @@ pub fn map_key(key: &str, accel: bool) -> Option<(Key, Option<String>)> {
     }
 }
 
+/// A clipboard shortcut, as the page's clipboard events are raised by it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ClipKey {
+    Copy,
+    Cut,
+    Paste,
+}
+
+/// Whether a `KeyboardEvent.key` with Ctrl (or ⌘) held, and Alt not, is a
+/// clipboard shortcut: the browser shell lets its default through, since
+/// that default is the `copy`, `cut` or `paste` event — and a page gets the
+/// clipboard's text only inside a `paste` event.
+pub fn clipboard_key(key: &str, accel: bool, alt: bool) -> Option<ClipKey> {
+    if !accel || alt {
+        return None;
+    }
+    match key {
+        "c" | "C" => Some(ClipKey::Copy),
+        "x" | "X" => Some(ClipKey::Cut),
+        "v" | "V" => Some(ClipKey::Paste),
+        _ => None,
+    }
+}
+
 /// A `WheelEvent`'s deltas as a scroll frame in the units the driver takes
 /// (a `wl_pointer` axis frame's: a finger in px, a wheel notch as a discrete
 /// step plus ten units, positive down and right — the DOM's signs too).
@@ -132,6 +156,18 @@ mod tests {
         assert_eq!(map_key("é", false), Some((Key::Character("é".into()), Some("é".into()))));
     }
 
+    #[test]
+    fn only_an_accelerated_c_x_or_v_is_a_clipboard_shortcut() {
+        assert_eq!(clipboard_key("c", true, false), Some(ClipKey::Copy));
+        assert_eq!(clipboard_key("X", true, false), Some(ClipKey::Cut));
+        assert_eq!(clipboard_key("v", true, false), Some(ClipKey::Paste));
+        // Ctrl+Shift+V, a paste too.
+        assert_eq!(clipboard_key("V", true, false), Some(ClipKey::Paste));
+        assert_eq!(clipboard_key("v", false, false), None);
+        assert_eq!(clipboard_key("v", true, true), None);
+        assert_eq!(clipboard_key("z", true, false), None);
+    }
+
     #[test]
     fn notches_are_discrete_and_a_trackpad_is_a_finger() {
         // Chromium's mouse wheel: 100 px a notch, three notches up.
diff --git a/src/web/shell.rs b/src/web/shell.rs
index 7397f6a..3c358a9 100644
--- a/src/web/shell.rs
+++ b/src/web/shell.rs
@@ -17,6 +17,15 @@
 //! - **Frames out.** [`build_frame`] at the canvas's CSS size and the page's
 //!   `devicePixelRatio`, drawn by the [`WebRenderer`].
 //!
+//! **The clipboard** comes through the page's clipboard events, since a page
+//! may read the clipboard only inside a `paste` event: a ⌘/Ctrl+V is held
+//! back from the app until its `paste` event has handed over the text (or,
+//! if none comes, until the task after), so the widget that pastes on it
+//! reads that text (`widget::clipboard`). A ⌘/Ctrl+C or X reaches the app at
+//! once, and the `copy` / `cut` event it raises carries whatever the app
+//! copied. The three keys' defaults are the only ones the canvas lets the
+//! page have.
+//!
 //! The page owns the canvas's place in it; [`Sizing`] says who owns its
 //! size. There is no context-menu popup surface here: the menu is drawn in
 //! the canvas and kept inside it (`context_menu::constrain_to`), as on a
@@ -30,15 +39,15 @@ use std::time::Duration;
 use cursor_icon::CursorIcon;
 use wasm_bindgen::prelude::*;
 use wasm_bindgen::JsCast;
-use web_sys::{AddEventListenerOptions, FocusEvent, HtmlCanvasElement, KeyboardEvent, PointerEvent, WheelEvent};
+use web_sys::{AddEventListenerOptions, ClipboardEvent, FocusEvent, HtmlCanvasElement, KeyboardEvent, PointerEvent, WheelEvent};
 
 use super::renderer::{Capture, WebRenderer};
 use crate::backend::app::{set_wake, AppSender, Application, LogicalPosition, LogicalSize};
-use crate::backend::dom::{map_key, wheel_frame};
+use crate::backend::dom::{clipboard_key, map_key, wheel_frame, ClipKey};
 use crate::backend::driver::{Driver, Modifiers, PressSite, ScrollFrame, ScrollSource, Turn};
 use crate::backend::frame::build_frame;
 use crate::backend::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
-use crate::widget::{context_menu, ElementState, MouseButton, TextItem};
+use crate::widget::{clipboard, context_menu, ElementState, Key, MouseButton, TextItem};
 
 /// Who decides the canvas's size.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -139,6 +148,8 @@ pub async fn run<A: Application>(canvas: HtmlCanvasElement, fonts: Fonts, sizing
         timer_cb: RefCell::new(None),
         finger_end_cb: RefCell::new(None),
         finger_timer: Cell::new(None),
+        held_paste: RefCell::new(None),
+        paste_cb: RefCell::new(None),
     });
     lp.shell.borrow_mut().measure();
     lp.shell.borrow_mut().just_configured = true;
@@ -395,6 +406,18 @@ struct Loop<A: Application> {
     finger_end_cb: RefCell<Option<Closure<dyn FnMut()>>>,
     /// The lift timer the last finger frame set; the next frame cancels it.
     finger_timer: Cell<Option<i32>>,
+    /// A ⌘/Ctrl+V held back until its `paste` event, and the timer that
+    /// lets it through if no event comes.
+    held_paste: RefCell<Option<HeldKey>>,
+    paste_cb: RefCell<Option<Closure<dyn FnMut()>>>,
+}
+
+/// A key press, kept to dispatch later: the key, its text and the
+/// modifiers it came with (ctrl, shift, alt, meta).
+struct HeldKey {
+    key: Key,
+    text: Option<String>,
+    mods: (bool, bool, bool, bool),
 }
 
 /// A browser reports no lift for a two-finger scroll: this long without a
@@ -474,6 +497,8 @@ impl<A: Application> Loop<A> {
         }));
         let l = lp.clone();
         *lp.finger_end_cb.borrow_mut() = Some(Closure::new(move || l.finger_lift()));
+        let l = lp.clone();
+        *lp.paste_cb.borrow_mut() = Some(Closure::new(move || l.release_paste()));
         let l = Rc::downgrade(lp);
         set_wake(Some(Box::new(move || {
             if let Some(l) = l.upgrade() {
@@ -568,6 +593,29 @@ impl<A: Application> Loop<A> {
                 })
             })?;
         }
+        // The clipboard's events, raised by the three keys `key` lets
+        // through. They go to the focused element or the body, so they are
+        // heard on the document.
+        if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
+            let doc: &web_sys::EventTarget = doc.as_ref();
+            let l = lp.clone();
+            listen(doc, "paste", true, move |e: ClipboardEvent| {
+                if let Some(text) = e.clipboard_data().and_then(|d| d.get_data("text/plain").ok()) {
+                    clipboard::pasted(text);
+                }
+                e.prevent_default();
+                l.release_paste();
+            })?;
+            for name in ["copy", "cut"] {
+                listen(doc, name, true, move |e: ClipboardEvent| {
+                    if let (Some(text), Some(data)) = (clipboard::take_copied(), e.clipboard_data()) {
+                        if data.set_data("text/plain", &text).is_ok() {
+                            e.prevent_default();
+                        }
+                    }
+                })?;
+            }
+        }
         // The page laying the canvas out anew is a configure; the turn it
         // wakes measures the box (`sync`).
         let l = lp.clone();
@@ -580,8 +628,11 @@ impl<A: Application> Loop<A> {
     }
 
     fn key(&self, e: &KeyboardEvent, state: ElementState) {
-        let Some((key, text)) = map_key(&e.key(), e.ctrl_key() || e.meta_key()) else { return };
-        if !passes_to_page(e) {
+        let accel = e.ctrl_key() || e.meta_key();
+        let Some((key, text)) = map_key(&e.key(), accel) else { return };
+        let clip = clipboard_key(&e.key(), accel, e.alt_key());
+        // A clipboard key's default is its clipboard event: the page keeps it.
+        if clip.is_none() && !passes_to_page(e) {
             e.prevent_default();
         }
         // The driver repeats a held key itself, at the toolkit's own rate
@@ -590,14 +641,53 @@ impl<A: Application> Loop<A> {
         if e.repeat() {
             return;
         }
+        let mods = (e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
+        if state == ElementState::Pressed {
+            match clip {
+                // Held until the `paste` event has handed over the text: it
+                // is raised after this listener returns, in the same task,
+                // so a zero timer is the fallback for a browser that raises
+                // none (the clipboard then reads what it read before).
+                Some(ClipKey::Paste) => {
+                    self.release_paste();
+                    *self.held_paste.borrow_mut() = Some(HeldKey { key, text, mods });
+                    if let Some(cb) = self.paste_cb.borrow().as_ref() {
+                        let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), 0);
+                    }
+                    return;
+                }
+                // A copy left over from a menu click is not this key's.
+                Some(ClipKey::Copy | ClipKey::Cut) => {
+                    clipboard::take_copied();
+                }
+                None => {}
+            }
+        } else {
+            // A release never overtakes the press it ends.
+            self.release_paste();
+        }
+        self.dispatch_key(HeldKey { key, text, mods }, state);
+    }
+
+    fn dispatch_key(&self, k: HeldKey, state: ElementState) {
+        let (ctrl, shift, alt, meta) = k.mods;
         self.event(|s| {
-            let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
+            let mods = s.mods_from(ctrl, shift, alt, meta);
             s.sync_mods(mods);
             let (driver, t) = s.turn();
-            driver.key(t, key, text, state);
+            driver.key(t, k.key, k.text, state);
         });
     }
 
+    /// Hand a held ⌘/Ctrl+V to the app, if one is held: its `paste` event
+    /// has come, or will not.
+    fn release_paste(&self) {
+        let held = self.held_paste.borrow_mut().take();
+        if let Some(k) = held {
+            self.dispatch_key(k, ElementState::Pressed);
+        }
+    }
+
     fn arm_finger_lift(&self) {
         let win = window();
         if let Some(t) = self.finger_timer.take() {
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 3f5c9e9..a2d998e 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -269,65 +269,173 @@ pub mod hover_animation {
     }
 }
 
+/// The system clipboard, as text: what every widget's copy, cut and paste go
+/// through. One synchronous pair, with a backend per platform:
+///
+/// - **Wayland** (Linux and the other non-Apple unixes): `wl-copy` /
+///   `wl-paste`, `xclip` where those are missing.
+/// - **macOS**: the general `NSPasteboard`, plain-text type.
+/// - **A page**: a browser hands a page the clipboard only inside a `paste`
+///   event, so a read is the text of the last one the browser shell saw (it
+///   holds a ⌘/Ctrl+V back until the event has come; `web::shell`), or what
+///   the page itself copied last. A copy writes through the async Clipboard
+///   API where the page is a secure context, and the shell also answers the
+///   `copy` / `cut` event a ⌘/Ctrl+C or X raises with it, which needs none.
+///   Before this a copy in a page panicked (`std::thread::spawn`).
 pub mod clipboard {
+    /// Put `text` on the clipboard.
     pub fn copy_to_clipboard(text: &str) {
-        let text = text.to_string();
-        std::thread::spawn(move || {
-            if let Ok(mut child) = std::process::Command::new("wl-copy")
-                .stdin(std::process::Stdio::piped())
-                .spawn()
+        imp::copy(text);
+    }
+
+    /// The clipboard's text, if it has any.
+    pub fn read_from_clipboard() -> Option<String> {
+        imp::read()
+    }
+
+    #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
+    mod imp {
+        pub fn copy(text: &str) {
+            let text = text.to_string();
+            std::thread::spawn(move || {
+                if let Ok(mut child) = std::process::Command::new("wl-copy")
+                    .stdin(std::process::Stdio::piped())
+                    .spawn()
+                {
+                    if let Some(mut stdin) = child.stdin.take() {
+                        use std::io::Write;
+                        let _ = stdin.write_all(text.as_bytes());
+                    }
+                    let _ = child.wait();
+                } else if let Ok(mut child) = std::process::Command::new("xclip")
+                    .arg("-selection")
+                    .arg("clipboard")
+                    .stdin(std::process::Stdio::piped())
+                    .spawn()
+                {
+                    if let Some(mut stdin) = child.stdin.take() {
+                        use std::io::Write;
+                        let _ = stdin.write_all(text.as_bytes());
+                    }
+                    let _ = child.wait();
+                }
+            });
+        }
+
+        pub fn read() -> Option<String> {
+            match std::process::Command::new("wl-paste")
+                .arg("-n")
+                .output()
             {
-                if let Some(mut stdin) = child.stdin.take() {
-                    use std::io::Write;
-                    let _ = stdin.write_all(text.as_bytes());
+                Ok(output) => {
+                    if output.status.success() {
+                        if let Ok(text) = String::from_utf8(output.stdout) {
+                            return Some(text);
+                        }
+                    }
                 }
-                let _ = child.wait();
-            } else if let Ok(mut child) = std::process::Command::new("xclip")
+                Err(_) => {}
+            }
+            match std::process::Command::new("xclip")
                 .arg("-selection")
                 .arg("clipboard")
-                .stdin(std::process::Stdio::piped())
-                .spawn()
+                .arg("-o")
+                .output()
             {
-                if let Some(mut stdin) = child.stdin.take() {
-                    use std::io::Write;
-                    let _ = stdin.write_all(text.as_bytes());
+                Ok(output) => {
+                    if output.status.success() {
+                        if let Ok(text) = String::from_utf8(output.stdout) {
+                            return Some(text);
+                        }
+                    }
                 }
-                let _ = child.wait();
+                Err(_) => {}
             }
-        });
+            None
+        }
     }
 
-    pub fn read_from_clipboard() -> Option<String> {
-        match std::process::Command::new("wl-paste")
-            .arg("-n")
-            .output()
-        {
-            Ok(output) => {
-                if output.status.success() {
-                    if let Ok(text) = String::from_utf8(output.stdout) {
-                        return Some(text);
-                    }
-                }
-            }
-            Err(_) => {}
-        }
-        match std::process::Command::new("xclip")
-            .arg("-selection")
-            .arg("clipboard")
-            .arg("-o")
-            .output()
-        {
-            Ok(output) => {
-                if output.status.success() {
-                    if let Ok(text) = String::from_utf8(output.stdout) {
-                        return Some(text);
-                    }
-                }
+    #[cfg(target_os = "macos")]
+    mod imp {
+        use objc2_app_kit::{NSPasteboard, NSPasteboardTypeString};
+        use objc2_foundation::NSString;
+
+        pub fn copy(text: &str) {
+            let board = NSPasteboard::generalPasteboard();
+            board.clearContents();
+            // SAFETY: an extern static AppKit defines.
+            let ty = unsafe { NSPasteboardTypeString };
+            board.setString_forType(&NSString::from_str(text), ty);
+        }
+
+        pub fn read() -> Option<String> {
+            // SAFETY: as above.
+            let ty = unsafe { NSPasteboardTypeString };
+            NSPasteboard::generalPasteboard().stringForType(ty).map(|s| s.to_string())
+        }
+    }
+
+    #[cfg(target_arch = "wasm32")]
+    mod imp {
+        use std::cell::RefCell;
+
+        #[derive(Default)]
+        struct Page {
+            /// What a read answers: the last paste the shell saw, or the
+            /// page's own last copy, whichever came later.
+            text: Option<String>,
+            /// A copy not yet handed to a `copy` / `cut` event.
+            copied: Option<String>,
+        }
+
+        thread_local! {
+            static PAGE: RefCell<Page> = RefCell::new(Page::default());
+        }
+
+        pub fn copy(text: &str) {
+            PAGE.with(|p| {
+                let mut p = p.borrow_mut();
+                p.text = Some(text.to_string());
+                p.copied = Some(text.to_string());
+            });
+            write_text(text);
+        }
+
+        pub fn read() -> Option<String> {
+            PAGE.with(|p| p.borrow().text.clone())
+        }
+
+        /// Write through the async Clipboard API, if the page has one: it is
+        /// undefined outside a secure context, and calling into undefined
+        /// would throw through the wasm frames. Its promise is awaited only
+        /// to keep a refusal (no user activation, no focus) off the console
+        /// as an unhandled rejection; the `copy` event path covers it.
+        fn write_text(text: &str) {
+            let Some(nav) = web_sys::window().map(|w| w.navigator()) else { return };
+            let has = js_sys::Reflect::get(&nav, &"clipboard".into()).is_ok_and(|c| !c.is_undefined() && !c.is_null());
+            if !has {
+                return;
             }
-            Err(_) => {}
+            let promise = nav.clipboard().write_text(text);
+            wasm_bindgen_futures::spawn_local(async move {
+                let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
+            });
+        }
+
+        /// The copy a `copy` / `cut` event should carry, taken.
+        pub(crate) fn take_copied() -> Option<String> {
+            PAGE.with(|p| p.borrow_mut().copied.take())
+        }
+
+        /// A `paste` event's text: what reads answer from now on.
+        pub(crate) fn pasted(text: String) {
+            PAGE.with(|p| p.borrow_mut().text = Some(text));
         }
-        None
     }
+
+    /// The browser shell's half: the clipboard events it answers.
+    #[cfg(target_arch = "wasm32")]
+    pub(crate) use imp::{pasted, take_copied};
 }
 
 pub mod context_menu {