denoland/deno · #35750

feat(desktop): clipboard api

crowlKats · merged Aug 26, 20267 files · 335 + / 4
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[] {