denoland/deno · #35750
feat(desktop): clipboard api
cli/rt/desktop.rs122 + / 0 −
@@ -30,6 +30,8 @@ pub const DESKTOP_JS: &str = r#" op_desktop_alert, op_desktop_confirm, op_desktop_prompt,+ op_desktop_read_clipboard_text,+ op_desktop_write_clipboard_text, op_desktop_request_notification_permission, op_desktop_query_notification_permission, } = internals.core.ops;@@ -696,6 +698,75 @@ pub const DESKTOP_JS: &str = r#" console.error("[deno desktop] failed to install navigator.permissions:", e); } + // --- navigator.clipboard (text only) ---+ //+ // Spec surface: `navigator.clipboard` is a `Clipboard` (extends EventTarget)+ // exposing async `readText()` / `writeText()`. The ops behind them are+ // genuinely async: laufey's clipboard calls block their calling thread, and+ // on X11/Wayland a read is serviced by whichever app owns the selection, so+ // an unresponsive owner would otherwise freeze the whole runtime behind a+ // Promise that looks like it couldn't. They reject rather than resolve if+ // that owner never answers — `""` is indistinguishable from an empty+ // clipboard, and a resolved `writeText()` has to mean the write happened.+ //+ // The richer `read()` / `write()` (`ClipboardItem` / arbitrary MIME types)+ // aren't backed by laufey, so they're omitted rather than stubbed. Per spec+ // the read/write are gated on the `clipboard-read` / `clipboard-write`+ // permissions, but laufey has no clipboard permission model, so access+ // isn't gated here (mirroring how the desktop Notification surface+ // degrades).+ const webidl = internals.webidl;+ class Clipboard extends EventTarget {+ constructor() {+ super();+ webidl.illegalConstructor();+ }++ async readText() {+ webidl.assertBranded(this, ClipboardPrototype);+ return (await op_desktop_read_clipboard_text()) ?? "";+ }++ async writeText(data) {+ webidl.assertBranded(this, ClipboardPrototype);+ const prefix = "Failed to execute 'writeText' on 'Clipboard'";+ webidl.requiredArguments(arguments.length, 1, prefix);+ data = webidl.converters["DOMString"](data, prefix, "Argument 1");+ await op_desktop_write_clipboard_text(data);+ }+ }+ webidl.configureInterface(Clipboard);+ const ClipboardPrototype = Clipboard.prototype;++ try {+ const clipboard = webidl.createBranded(Clipboard);+ // createBranded skips the constructor, so initialize the EventTarget+ // internal slots explicitly (see ext/web/02_event.js setEventTargetData).+ internals.setEventTargetData(clipboard);++ if (typeof navigator === "object" && navigator != null) {+ // Install as a prototype getter (as in browsers) rather than an own+ // data property, asserting the receiver is a real Navigator.+ const NavigatorPrototype = Object.getPrototypeOf(navigator);+ Object.defineProperty(NavigatorPrototype, "clipboard", {+ get() {+ webidl.assertBranded(this, NavigatorPrototype);+ return clipboard;+ },+ enumerable: true,+ configurable: true,+ });+ }+ Object.defineProperty(globalThis, "Clipboard", {+ value: Clipboard,+ writable: true,+ enumerable: false,+ configurable: true,+ });+ } catch (e) {+ console.error("[deno desktop] failed to install navigator.clipboard:", e);+ }+ // Start polling loops immediately. Use core.unrefOpPromise so these // pending ops don't block event loop completion (e.g. the pre-module // tick used by HMR, or module evaluation with top-level await).@@ -1293,6 +1364,57 @@ mod tests { assert!(DESKTOP_JS.contains("PermissionStatus")); } + #[test]+ fn desktop_js_installs_navigator_clipboard() {+ // Assert on code, not prose: every one of "navigator", "clipboard",+ // "readText" and "writeText" also appears in the comment block above the+ // implementation, so substring checks on those words alone would still+ // pass with the whole class deleted.+ assert!(+ DESKTOP_JS.contains("class Clipboard extends EventTarget"),+ "Clipboard must be a real EventTarget subclass"+ );+ assert!(+ DESKTOP_JS.contains("async readText()"),+ "readText must be defined on the class"+ );+ assert!(+ DESKTOP_JS.contains("async writeText(data)"),+ "writeText must be defined on the class"+ );+ // Installed as a prototype getter on Navigator, as in browsers, rather+ // than an own data property on the instance.+ assert!(+ DESKTOP_JS.contains("defineProperty(NavigatorPrototype, \"clipboard\""),+ "clipboard must be installed on Navigator.prototype"+ );+ // The constructor is not reachable, and the receiver is checked.+ assert!(+ DESKTOP_JS.contains("webidl.illegalConstructor()"),+ "Clipboard must not be constructible"+ );+ assert!(+ DESKTOP_JS.contains("webidl.assertBranded(this, ClipboardPrototype)"),+ "the text methods must assert a branded receiver"+ );+ }++ #[test]+ fn desktop_js_clipboard_awaits_the_ops() {+ // The ops are async so a slow clipboard owner can't freeze the runtime+ // (see op_desktop_read_clipboard_text). That only holds if the JS side+ // actually awaits them — dropping the `await` would return a pending+ // promise as the text and silently break `readText()`.+ assert!(+ DESKTOP_JS.contains("await op_desktop_read_clipboard_text()"),+ "readText must await the op"+ );+ assert!(+ DESKTOP_JS.contains("await op_desktop_write_clipboard_text(data)"),+ "writeText must await the op"+ );+ }+ #[test] fn desktop_js_installs_browser_window_constructor() { assert!(DESKTOP_JS.contains("Deno.BrowserWindow"));cli/rt_desktop/lib.rs8 + / 0 −
@@ -612,6 +612,14 @@ impl denort::desktop::DesktopApi for WefDesktopApi { laufey::prompt(title, message, default_value) } + fn read_clipboard_text(&self) -> Option<String> {+ laufey::read_clipboard_text()+ }++ fn write_clipboard_text(&self, text: &str) {+ laufey::write_clipboard_text(text);+ }+ fn set_dock_badge(&self, text: &str) { laufey::set_dock_badge(if text.is_empty() { None } else { Some(text) }); }cli/tsc/dts/lib.deno.desktop.d.ts28 + / 1 −
@@ -240,10 +240,37 @@ declare interface Permissions { query(descriptor: PermissionDescriptor): Promise<PermissionStatus>; } +/** Read from and write plain text to the system clipboard. A subset of the+ * web [Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard);+ * only the text methods are backed by `deno desktop`. */+declare interface Clipboard extends EventTarget {+ /** Resolve with the clipboard's text content, or an empty string when the+ * clipboard is empty or holds no text.+ *+ * Rejects if the clipboard doesn't respond — on Linux the read is serviced+ * by whichever application owns the selection, so an unresponsive one+ * fails rather than resolving to an empty string it can't be told apart+ * from. */+ readText(): Promise<string>;+ /** Replace the clipboard's content with `data`. An empty string clears the+ * clipboard.+ *+ * Resolving means the write completed; it rejects if the clipboard doesn't+ * respond. */+ writeText(data: string): Promise<void>;+}++/** `Clipboard` has no constructor: the only instance is+ * {@linkcode Navigator.clipboard}. */+declare var Clipboard: {+ prototype: Clipboard;+};+ /** Extends the {@linkcode Navigator} provided by `deno.window` with the- * Permissions API surface available to `deno desktop` apps. */+ * Permissions and Clipboard API surface available to `deno desktop` apps. */ declare interface Navigator { readonly permissions: Permissions;+ readonly clipboard: Clipboard; } declare namespace Deno {ext/webidl/00_webidl.js19 + / 0 −
@@ -1645,6 +1645,25 @@ function setlikeObjectWrap(objPrototype, readonly) { } internals.webidlBrand = brand;+// The subset of webidl needed by post-bootstrap classic scripts that can't+// `import` this module (e.g. the `deno desktop` init script, which defines+// web interfaces like `navigator.clipboard`).+//+// `converters` is deliberately NOT exposed. It is the live registry backing+// argument coercion for every web API in the process, and this object lands+// in every Deno runtime rather than just desktop, so handing it out would+// let anything with `internals` access rewrite how any API coerces its+// arguments. Only the individual converters a consumer needs belong here.+internals.webidl = ObjectFreeze({+ assertBranded,+ configureInterface,+ createBranded,+ illegalConstructor,+ requiredArguments,+ converters: ObjectFreeze({+ DOMString: converters["DOMString"],+ }),+}); return { assertBranded,runtime/js/99_main.js22 + / 2 −
@@ -726,6 +726,8 @@ const NOT_IMPORTED_OPS = [ "op_desktop_alert", "op_desktop_confirm", "op_desktop_prompt",+ "op_desktop_read_clipboard_text",+ "op_desktop_write_clipboard_text", "op_desktop_send_error_report", "op_desktop_request_notification_permission", "op_desktop_query_notification_permission",@@ -736,10 +738,28 @@ const NOT_IMPORTED_OPS = [ "op_deploy_token_delete", ]; -function removeImportedOps() {+// Ops from NOT_IMPORTED_OPS that stay out of worker scope.+//+// The Clipboard API is `partial interface Navigator` in the spec, so the web+// exposes `navigator.clipboard` on Window only; `WorkerNavigator` has no+// clipboard. The desktop init script installs the JS API in the main scope+// for the same reason, so leaving the raw ops reachable in workers would give+// a plain `new Worker(...)` the ability to read whatever the user last copied+// with no matching API and no way to ask for it. The other desktop ops are+// dialogs, notifications and update plumbing: either user-visible or inert.+const WORKER_EXCLUDED_OPS = [+ "op_desktop_read_clipboard_text",+ "op_desktop_write_clipboard_text",+];++function removeImportedOps(isWorker = false) { const allOpNames = ObjectKeys(ops); for (let i = 0; i < allOpNames.length; i++) { const opName = allOpNames[i];+ if (isWorker && ArrayPrototypeIncludes(WORKER_EXCLUDED_OPS, opName)) {+ delete ops[opName];+ continue;+ } if (!ArrayPrototypeIncludes(NOT_IMPORTED_OPS, opName)) { delete ops[opName]; }@@ -1189,7 +1209,7 @@ function bootstrapWorkerRuntime( closeOnIdle = runtimeOptions[14]; - removeImportedOps();+ removeImportedOps(true); performance.setTimeOrigin(); globalThis_ = globalThis;runtime/ops/desktop.rs133 + / 0 −
@@ -419,6 +419,14 @@ pub trait DesktopApi: Send + Sync + 'static { default_value: &str, ) -> Option<String>; + /// Read the system clipboard's plain-text content. Returns `None` if the+ /// clipboard is empty, holds no text, or the backend has no clipboard+ /// support.+ fn read_clipboard_text(&self) -> Option<String>;+ /// Replace the system clipboard's content with `text`. An empty string+ /// clears the clipboard.+ fn write_clipboard_text(&self, text: &str);+ /// Set a short text badge on the app's dock / taskbar icon. An empty /// string clears the badge. fn set_dock_badge(&self, text: &str);@@ -1337,6 +1345,129 @@ fn op_desktop_prompt( } } +/// How long to wait on a clipboard call before giving up.+///+/// On X11 and Wayland there is no central clipboard store: the call is+/// serviced by whichever application owns the selection, and+/// `gtk_clipboard_wait_for_text` has no timeout of its own. The blocking pool+/// these calls run on is shared and bounded, so an unbounded wait turns one+/// unresponsive peer into starvation for every other blocking task in the+/// runtime. Short enough not to strand a caller, long enough that a merely+/// slow owner still succeeds.+const CLIPBOARD_TIMEOUT: std::time::Duration =+ std::time::Duration::from_secs(5);++/// Read the clipboard's text off the JS thread.+///+/// `DesktopApi::read_clipboard_text` is synchronous, and on X11/Wayland the+/// clipboard has no central store: the read is serviced by whichever+/// application currently owns the selection, and `gtk_clipboard_wait_for_text`+/// has no timeout. An unresponsive owner therefore blocks the caller for as+/// long as it likes. Running that on the JS thread would freeze the entire+/// runtime — timers, servers, signal handlers — which is the same failure+/// mode as the error dialog in #36393, and the `Promise` this op returns to+/// `navigator.clipboard.readText()` would have made it look impossible.+///+/// Thread-safety: in a packaged desktop app the runtime already runs on its+/// own `deno-desktop-runtime` thread (`run_on_runtime_thread` in+/// `cli/rt_desktop`), never the laufey UI thread, so the existing call was+/// already an off-UI-thread one that the backend marshals; the pool thread+/// used here is in the same position, not a new kind of caller.+#[op2]+#[string]+async fn op_desktop_read_clipboard_text(+ state: std::rc::Rc<std::cell::RefCell<OpState>>,+) -> Result<Option<String>, deno_error::JsErrorBox> {+ let api = {+ let s = state.borrow();+ s.try_borrow::<Arc<dyn DesktopApi>>().cloned()+ };+ let Some(api) = api else {+ return Ok(None);+ };+ // The runtime's bounded blocking pool, not a fresh thread per call:+ // `readText()` is an ordinary API an app may poll on an interval, and+ // nothing here rate-limits it.+ //+ // The pool being bounded is also why the timeout matters. A read is+ // serviced by whichever app owns the selection and can block for as long+ // as that app likes, and a `spawn_blocking` task can't be cancelled — so+ // without a bound on the wait, enough hung reads would occupy pool threads+ // permanently and starve every other blocking task in the runtime, not+ // just the caller. Timing out doesn't reclaim the thread, but it stops the+ // caller adding more of them behind an unbounded await.+ //+ // A join error (a backend that panics mid-read) yields `None` too, rather+ // than leaving the caller's promise pending forever.+ let read =+ deno_core::unsync::spawn_blocking(move || api.read_clipboard_text());+ // Reject rather than resolve on either failure. Resolving would hand back+ // `""`, which is exactly what a genuinely empty clipboard returns, so a+ // caller could not tell "nothing was copied" from "the owning app is+ // wedged" — and `if (await navigator.clipboard.readText())` would quietly+ // take the empty branch. The spec rejects here too.+ //+ // The two failures get different messages: a panic inside the backend has+ // nothing to do with a timeout or with another application, and pointing+ // someone at their window manager for it would be an actively wrong+ // diagnosis.+ match tokio::time::timeout(CLIPBOARD_TIMEOUT, read).await {+ Ok(Ok(text)) => Ok(text),+ Ok(Err(_join)) => Err(clipboard_failed("read")),+ Err(_elapsed) => Err(clipboard_unavailable("read")),+ }+}++/// The error a clipboard op rejects with when the backend call itself failed+/// — i.e. it panicked, so the blocking task's join returned an error. Kept+/// distinct from [`clipboard_unavailable`]: nothing timed out and no other+/// application was involved.+fn clipboard_failed(op: &str) -> deno_error::JsErrorBox {+ deno_error::JsErrorBox::generic(format!("clipboard {op} failed"))+}++/// The error a clipboard op rejects with when the call didn't finish in time.+///+/// Names the unresponsive-owner case specifically: on X11/Wayland the call is+/// serviced by whichever application owns the selection, and that being stuck+/// is the one thing a user can actually act on. Only for the timeout — see+/// [`clipboard_failed`] for a backend that failed outright.+fn clipboard_unavailable(op: &str) -> deno_error::JsErrorBox {+ deno_error::JsErrorBox::generic(format!(+ "clipboard {op} did not complete within {}s - the application that owns \+ the clipboard may be unresponsive",+ CLIPBOARD_TIMEOUT.as_secs()+ ))+}++/// Write the clipboard's text off the JS thread. Blocking semantics — and the+/// reason for going through the blocking pool — as+/// `op_desktop_read_clipboard_text`.+#[op2]+async fn op_desktop_write_clipboard_text(+ state: std::rc::Rc<std::cell::RefCell<OpState>>,+ #[string] text: String,+) -> Result<(), deno_error::JsErrorBox> {+ let api = {+ let s = state.borrow();+ s.try_borrow::<Arc<dyn DesktopApi>>().cloned()+ };+ let Some(api) = api else {+ return Ok(());+ };+ let write = deno_core::unsync::spawn_blocking(move || {+ api.write_clipboard_text(&text);+ });+ // `writeText()`'s whole contract is that resolution means the write+ // happened, so discarding the timeout here would make an app report+ // "Copied!" in precisely the case the timeout exists to catch.+ match tokio::time::timeout(CLIPBOARD_TIMEOUT, write).await {+ Ok(Ok(())) => Ok(()),+ Ok(Err(_join)) => Err(clipboard_failed("write")),+ Err(_elapsed) => Err(clipboard_unavailable("write")),+ }+}+ fn permission_state_to_web_string(state: PermissionState) -> &'static str { // Web Permissions API state values; `Notification.requestPermission` // additionally maps `Prompt` → `"default"` per the Notifications spec.@@ -1765,6 +1896,8 @@ deno_core::extension!( op_desktop_alert, op_desktop_confirm, op_desktop_prompt,+ op_desktop_read_clipboard_text,+ op_desktop_write_clipboard_text, op_desktop_send_error_report, op_desktop_request_notification_permission, op_desktop_query_notification_permission,tests/unit/ops_test.ts3 + / 1 −
@@ -1,6 +1,8 @@ // Copyright 2018-2026 the Deno authors. MIT license. -const EXPECTED_OP_COUNT = 42;+const EXPECTED_OP_COUNT = 44;+// Two fewer than the main scope: the clipboard ops are stripped from workers+// (see WORKER_EXCLUDED_OPS in 99_main.js). const EXPECTED_WORKER_OP_COUNT = 20; function getExposedOpNames(): string[] {