diff --git a/docs/runtime/web-apis.mdx b/docs/runtime/web-apis.mdx index 9aeced07991a..5953ce1685e4 100644 --- a/docs/runtime/web-apis.mdx +++ b/docs/runtime/web-apis.mdx @@ -8,22 +8,23 @@ Some Web APIs, like the [DOM API](https://developer.mozilla.org/en-US/docs/Web/A Bun partially or completely supports the following Web APIs. -| Category | APIs | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| HTTP | [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch), [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response), [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request), [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers), [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) | -| URLs | [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL), [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) | -| Web Workers | [`Worker`](https://developer.mozilla.org/en-US/docs/Web/API/Worker), [`self.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/postMessage), [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone), [`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort), [`MessageChannel`](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel), [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) | -| Streams | [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream), [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream), [`TransformStream`](https://developer.mozilla.org/en-US/docs/Web/API/TransformStream), [`ByteLengthQueuingStrategy`](https://developer.mozilla.org/en-US/docs/Web/API/ByteLengthQueuingStrategy), [`CountQueuingStrategy`](https://developer.mozilla.org/en-US/docs/Web/API/CountQueuingStrategy) and associated classes | -| Blob | [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) | -| WebSockets | [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) | -| Encoding and decoding | [`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array), [`Uint8Array.prototype.toBase64()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toBase64), [`Uint8Array.fromBase64()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/fromBase64), [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder), [`TextDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder), [`atob`](https://developer.mozilla.org/en-US/docs/Web/API/atob), [`btoa`](https://developer.mozilla.org/en-US/docs/Web/API/btoa) | -| JSON | [`JSON`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON) | -| Timeouts | [`setTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout), [`clearTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout) | -| Intervals | [`setInterval`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval), [`clearInterval`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval) | -| Crypto | [`crypto`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto), [`SubtleCrypto`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto), [`CryptoKey`](https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey) | -| Debugging | [`console`](https://developer.mozilla.org/en-US/docs/Web/API/console), [`performance`](https://developer.mozilla.org/en-US/docs/Web/API/Performance) | -| Microtasks | [`queueMicrotask`](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask) | -| Errors | [`reportError`](https://developer.mozilla.org/en-US/docs/Web/API/reportError) | -| User interaction | [`alert`](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert), [`confirm`](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm), [`prompt`](https://developer.mozilla.org/en-US/docs/Web/API/Window/prompt) (intended for interactive CLIs) | -| Realms | [`ShadowRealm`](https://github.com/tc39/proposal-shadowrealm) | -| Events | [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget), [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event), [`ErrorEvent`](https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent), [`CloseEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent), [`MessageEvent`](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent) | +| Category | APIs | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| HTTP | [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch), [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response), [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request), [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers), [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) | +| URLs | [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL), [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) | +| Web Workers | [`Worker`](https://developer.mozilla.org/en-US/docs/Web/API/Worker), [`self.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/postMessage), [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone), [`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort), [`MessageChannel`](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel), [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) | +| Streams | [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream), [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream), [`TransformStream`](https://developer.mozilla.org/en-US/docs/Web/API/TransformStream), [`ByteLengthQueuingStrategy`](https://developer.mozilla.org/en-US/docs/Web/API/ByteLengthQueuingStrategy), [`CountQueuingStrategy`](https://developer.mozilla.org/en-US/docs/Web/API/CountQueuingStrategy) and associated classes | +| Blob | [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) | +| WebSockets | [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) | +| Encoding and decoding | [`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array), [`Uint8Array.prototype.toBase64()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toBase64), [`Uint8Array.fromBase64()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/fromBase64), [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder), [`TextDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder), [`atob`](https://developer.mozilla.org/en-US/docs/Web/API/atob), [`btoa`](https://developer.mozilla.org/en-US/docs/Web/API/btoa) | +| JSON | [`JSON`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON) | +| Timeouts | [`setTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout), [`clearTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout) | +| Intervals | [`setInterval`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval), [`clearInterval`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval) | +| Crypto | [`crypto`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto), [`SubtleCrypto`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto), [`CryptoKey`](https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey) | +| Debugging | [`console`](https://developer.mozilla.org/en-US/docs/Web/API/console), [`performance`](https://developer.mozilla.org/en-US/docs/Web/API/Performance) | +| Microtasks | [`queueMicrotask`](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask) | +| Errors | [`reportError`](https://developer.mozilla.org/en-US/docs/Web/API/reportError) | +| User interaction | [`alert`](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert), [`confirm`](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm), [`prompt`](https://developer.mozilla.org/en-US/docs/Web/API/Window/prompt) (intended for interactive CLIs) | +| Clipboard | [`navigator.clipboard`](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard) — [`readText()`](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText), [`writeText()`](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText), [`read()`](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/read), [`write()`](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/write), [`ClipboardItem`](https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem), [`ClipboardEvent`](https://developer.mozilla.org/en-US/docs/Web/API/ClipboardEvent) (`copy`/`paste` fire at `navigator.clipboard` after its own successful writes/reads). `text/plain`, `text/html`, and `image/png` on every platform. On Linux this requires a `$WAYLAND_DISPLAY` or `$DISPLAY` and `wl-paste`/`wl-copy` or `xclip` (`xsel` can only read text). | +| Realms | [`ShadowRealm`](https://github.com/tc39/proposal-shadowrealm) | +| Events | [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget), [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event), [`ErrorEvent`](https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent), [`CloseEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent), [`MessageEvent`](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent) | diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index 1a9ef0cb58ca..e45ada77ff52 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -55,6 +55,12 @@ declare module "bun" { type LibEmptyOrBroadcastChannel = LibDomIsLoaded extends true ? {} : import("node:worker_threads").BroadcastChannel; type LibEmptyOrEventSource = LibDomIsLoaded extends true ? {} : import("undici-types").EventSource; + interface BunClipboardEvent extends Event { + /** Always `null`: Bun does not implement `DataTransfer`. */ + readonly clipboardData: null; + } + type LibEmptyOrBunClipboardEvent = LibDomIsLoaded extends true ? {} : BunClipboardEvent; + type LibEmptyOrReadableByteStreamController = LibDomIsLoaded extends true ? {} : import("node:stream/web").ReadableByteStreamController; @@ -1478,10 +1484,183 @@ interface Navigator { readonly userAgent: string; readonly platform: "MacIntel" | "Win32" | "Linux x86_64"; readonly hardwareConcurrency: number; + /** + * The system clipboard, from the + * [Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API). + * + * Supported representations on every platform: `text/plain`, + * `text/html`, and `image/png`. + * + * On Linux this drives `wl-paste`/`wl-copy` (Wayland) or `xclip`, with + * `xsel` as a fallback for reading text, so one of those must be installed and `$WAYLAND_DISPLAY` or + * `$DISPLAY` must be set; otherwise the methods reject with a + * `"NotAllowedError"` `DOMException`. + * + * @example + * ```ts + * await navigator.clipboard.writeText("Hello from Bun!"); + * console.log(await navigator.clipboard.readText()); + * ``` + */ + readonly clipboard: Clipboard; } declare var navigator: Navigator; +/** + * The async [Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard). + * + * Reachable as the `navigator.clipboard` singleton. It has no public + * constructor: `new Clipboard()` throws a `TypeError`. + * + * Bun fires a `"copy"` `ClipboardEvent` at `navigator.clipboard` after every + * successful `writeText()` or `write()` that places data on the clipboard + * (an empty `write([])` is a no-op), and a `"paste"` after every successful + * `read()`/`readText()`: there is no document or focused element in a + * runtime, so this `EventTarget` stands in for the spec's event target. + * `"cut"` is never fired automatically (there is no selection to remove). + * + * @example + * ```ts + * navigator.clipboard.addEventListener("copy", () => console.log("copied")); + * await navigator.clipboard.writeText("hi"); // logs "copied" + * ``` + */ +interface Clipboard extends EventTarget { + /** + * Read the system clipboard's plain-text contents. + * + * @returns the text, or `""` when nothing (or nothing textual) is on the + * clipboard. Rejects with a `"NotAllowedError"` `DOMException` when the + * platform clipboard cannot be reached at all, or when the text is too + * large for a string. + */ + readText(): Promise; + /** + * Replace the system clipboard's contents with `data` as plain text. + * + * Rejects with a `"NotAllowedError"` `DOMException` when the platform + * clipboard cannot be reached at all. + */ + writeText(data: string): Promise; + /** + * Read every supported representation of the clipboard's current item as a + * single `ClipboardItem`, in one operation. + * + * @returns `[item]`, or `[]` when no supported representation is present. + * Rejects with a `"NotAllowedError"` `DOMException` when the platform + * clipboard cannot be reached at all. + * + * @example + * ```ts + * for (const item of await navigator.clipboard.read()) { + * if (item.types.includes("image/png")) { + * await Bun.write("clip.png", await item.getType("image/png")); + * } + * } + * ``` + */ + read(): Promise; + /** + * Replace the system clipboard with the representations of one + * `ClipboardItem` (every type `ClipboardItem.supports()` reports). + * + * Rejects with a `"NotAllowedError"` `DOMException` if any representation + * is unsupported, if more than one item is passed, on Linux/BSD if the + * item has more than one representation (the helper programs can only own + * one), or when the platform clipboard cannot be reached. + * + * Writes are not queued: one that is not awaited can land before or after + * a later `write()`/`writeText()`. + */ + write(data: ClipboardItem[]): Promise; +} +declare var Clipboard: Bun.__internal.UseLibDomIfAvailable< + "Clipboard", + { + prototype: Clipboard; + /** + * Lets `x instanceof Clipboard` type-check (it narrows to `Clipboard`). + * Deliberately no `new ()`: per the spec `Clipboard` has no constructor, + * so `new Clipboard()` is a compile error and a runtime `TypeError`. + */ + [Symbol.hasInstance](value: unknown): value is Clipboard; + } +>; + +/** + * One clipboard "item" and its representations, keyed by MIME type. + * [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem) + * + * Bun supports `text/plain`, `text/html`, and `image/png` on every + * platform (on Linux, `xsel` only reads text). + */ +interface ClipboardItem { + /** The MIME types this item holds, in insertion order (frozen). */ + readonly types: readonly string[]; + /** How the item should be presented; defaults to `"unspecified"`. */ + readonly presentationStyle: "unspecified" | "inline" | "attachment"; + /** + * The item's representation for `type` as a `Blob` whose `type` matches. + * Rejects with a `"NotFoundError"` `DOMException` for a type the item does + * not hold. + */ + getType(type: string): Promise; +} +declare var ClipboardItem: Bun.__internal.UseLibDomIfAvailable< + "ClipboardItem", + { + prototype: ClipboardItem; + /** + * @param items one entry per MIME type; each value is a string, a `Blob`, + * or a promise of either, resolved lazily by `getType()`/`write()`. + * @example + * ```ts + * await navigator.clipboard.write([ + * new ClipboardItem({ "text/plain": "hi", "text/html": "hi" }), + * ]); + * ``` + */ + new ( + items: Record>, + options?: { presentationStyle?: "unspecified" | "inline" | "attachment" }, + ): ClipboardItem; + /** + * Whether Bun handles `type`. On Linux it does not check which helper + * program is installed. + */ + supports(type: string): boolean; + } +>; + +/** + * The `copy`/`cut`/`paste` event type. + * [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ClipboardEvent) + * + * Bun fires `"copy"` and `"paste"` at `navigator.clipboard` after its own + * successful write/read operations (there is no document, focused element, + * or user gesture in a runtime), and never fires `"cut"`. The class is also + * constructible, so synthetic events can be dispatched through any + * `EventTarget`. `clipboardData` is always `null` at runtime because Bun + * does not implement `DataTransfer`. + */ +interface ClipboardEvent extends Bun.__internal.LibEmptyOrBunClipboardEvent {} +declare var ClipboardEvent: Bun.__internal.UseLibDomIfAvailable< + "ClipboardEvent", + { + prototype: ClipboardEvent; + new ( + type: string, + eventInitDict?: { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + clipboardData?: null; + }, + ): ClipboardEvent; + } +>; + interface BlobPropertyBag { /** Set a default "type". Not yet implemented. */ type?: string; diff --git a/src/codegen/generate-js2native.ts b/src/codegen/generate-js2native.ts index 343b603abced..6440665b8c24 100644 --- a/src/codegen/generate-js2native.ts +++ b/src/codegen/generate-js2native.ts @@ -91,6 +91,7 @@ const rustIdentifierPaths: Record = { "runtime/socket/socket.rs": "runtime/socket/socket.rs", "runtime/timer/Timer.rs": "runtime/timer/Timer.rs", "runtime/webcore/ByteStream.rs": "runtime/webcore/ByteStream.rs", + "runtime/webcore/clipboard.rs": "runtime/webcore/clipboard.rs", "runtime/webcore/FileSink.rs": "runtime/webcore/FileSink.rs", "runtime/webcore/fetch.rs": "runtime/webcore/fetch.rs", "shell.rs": "runtime/shell/shell.rs", diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 79099c16ce05..6e80eb54db7f 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -188,6 +188,14 @@ export const setMaxMarkdownBlockBytesForTesting: (limit: number) => number = $ne 1, ); +// How long one run of a clipboard helper program (`wl-paste`, `xclip`, ...) may +// take, in milliseconds. Returns the previous limit. +export const setClipboardHelperTimeoutForTesting: (milliseconds: number) => number = $newRustFunction( + "runtime/webcore/clipboard.rs", + "setHelperTimeoutForTesting", + 1, +); + export const npm_manifest_test_helpers = $rust("npm.rs", "PackageManifest.bindings.generate") as { /** * Returns the parsed manifest file. Currently only returns an array of available versions. diff --git a/src/jsc/bindings/DOMStructureSlot.h b/src/jsc/bindings/DOMStructureSlot.h index 5a385c23b5b6..97b7c40482f2 100644 --- a/src/jsc/bindings/DOMStructureSlot.h +++ b/src/jsc/bindings/DOMStructureSlot.h @@ -5,67 +5,70 @@ namespace WebCore { // Every wrapper class whose Structure is cached on the global object gets one fixed slot here. -#define FOR_EACH_DOM_STRUCTURE_SLOT(macro) \ - macro(JSAbortController) \ - macro(JSAbortSignal) \ - macro(JSBroadcastChannel) \ - macro(JSByteLengthQueuingStrategy) \ - macro(JSCloseEvent) \ - macro(JSCompressionStream) \ - macro(JSCookie) \ - macro(JSCookieMap) \ - macro(JSCountQueuingStrategy) \ - macro(JSCryptoKey) \ - macro(JSCustomEvent) \ - macro(JSDOMException) \ - macro(JSDOMFormData) \ - macro(JSDOMURL) \ - macro(JSDecompressionStream) \ - macro(JSErrorEvent) \ - macro(JSEvent) \ - macro(JSEventEmitter) \ - macro(JSEventTarget) \ - macro(JSFetchHeaders) \ - macro(JSMessageChannel) \ - macro(JSMessageEvent) \ - macro(JSMessagePort) \ - macro(JSPerformance) \ - macro(JSPerformanceEntry) \ - macro(JSPerformanceMark) \ - macro(JSPerformanceMeasure) \ - macro(JSPerformanceObserver) \ - macro(JSPerformanceObserverEntryList) \ - macro(JSPerformanceResourceTiming) \ - macro(JSPerformanceServerTiming) \ - macro(JSPerformanceTiming) \ - macro(JSReadableArrayBufferSinkController) \ - macro(JSReadableFetchRequestBodySinkController) \ - macro(JSReadableFileSinkController) \ - macro(JSReadableHTMLRewriterSinkController) \ - macro(JSReadableHTTPResponseSinkController) \ - macro(JSReadableHTTPSResponseSinkController) \ - macro(JSReadableNetworkSinkController) \ - macro(JSReadableByteStreamController) \ - macro(JSReadableStream) \ - macro(JSReadableStreamAsyncIterator) \ - macro(JSReadableStreamBYOBReader) \ - macro(JSReadableStreamBYOBRequest) \ - macro(JSReadableStreamDefaultController) \ - macro(JSReadableStreamDefaultReader) \ - macro(JSSubtleCrypto) \ - macro(JSTextDecoderStream) \ - macro(JSTextEncoder) \ - macro(JSTextEncoderStream) \ - macro(JSTransformStream) \ - macro(JSTransformStreamDefaultController) \ - macro(JSURLPattern) \ - macro(JSURLSearchParams) \ - macro(JSWasmStreamingCompiler) \ - macro(JSWebSocket) \ - macro(JSWorker) \ - macro(JSWritableStream) \ - macro(JSWritableStreamDefaultController) \ - macro(JSWritableStreamDefaultWriter) +#define FOR_EACH_DOM_STRUCTURE_SLOT(macro) \ + macro(JSAbortController) \ + macro(JSAbortSignal) \ + macro(JSBroadcastChannel) \ + macro(JSByteLengthQueuingStrategy) \ + macro(JSClipboard) \ + macro(JSClipboardEvent) \ + macro(JSClipboardItem) \ + macro(JSCloseEvent) \ + macro(JSCompressionStream) \ + macro(JSCookie) \ + macro(JSCookieMap) \ + macro(JSCountQueuingStrategy) \ + macro(JSCryptoKey) \ + macro(JSCustomEvent) \ + macro(JSDOMException) \ + macro(JSDOMFormData) \ + macro(JSDOMURL) \ + macro(JSDecompressionStream) \ + macro(JSErrorEvent) \ + macro(JSEvent) \ + macro(JSEventEmitter) \ + macro(JSEventTarget) \ + macro(JSFetchHeaders) \ + macro(JSMessageChannel) \ + macro(JSMessageEvent) \ + macro(JSMessagePort) \ + macro(JSPerformance) \ + macro(JSPerformanceEntry) \ + macro(JSPerformanceMark) \ + macro(JSPerformanceMeasure) \ + macro(JSPerformanceObserver) \ + macro(JSPerformanceObserverEntryList) \ + macro(JSPerformanceResourceTiming) \ + macro(JSPerformanceServerTiming) \ + macro(JSPerformanceTiming) \ + macro(JSReadableArrayBufferSinkController) \ + macro(JSReadableFetchRequestBodySinkController) \ + macro(JSReadableFileSinkController) \ + macro(JSReadableHTMLRewriterSinkController) \ + macro(JSReadableHTTPResponseSinkController) \ + macro(JSReadableHTTPSResponseSinkController) \ + macro(JSReadableNetworkSinkController) \ + macro(JSReadableByteStreamController) \ + macro(JSReadableStream) \ + macro(JSReadableStreamAsyncIterator) \ + macro(JSReadableStreamBYOBReader) \ + macro(JSReadableStreamBYOBRequest) \ + macro(JSReadableStreamDefaultController) \ + macro(JSReadableStreamDefaultReader) \ + macro(JSSubtleCrypto) \ + macro(JSTextDecoderStream) \ + macro(JSTextEncoder) \ + macro(JSTextEncoderStream) \ + macro(JSTransformStream) \ + macro(JSTransformStreamDefaultController) \ + macro(JSURLPattern) \ + macro(JSURLSearchParams) \ + macro(JSWasmStreamingCompiler) \ + macro(JSWebSocket) \ + macro(JSWorker) \ + macro(JSWritableStream) \ + macro(JSWritableStreamDefaultController) \ + macro(JSWritableStreamDefaultWriter) #define DOM_STRUCTURE_SLOT_FORWARD_DECLARE(name) class name; FOR_EACH_DOM_STRUCTURE_SLOT(DOM_STRUCTURE_SLOT_FORWARD_DECLARE) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 2e2465e0244d..456526135231 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -94,6 +94,9 @@ #include "JSBroadcastChannel.h" #include "JSBuffer.h" #include "streams/JSByteLengthQueuingStrategy.h" +#include "JSClipboard.h" +#include "JSClipboardEvent.h" +#include "JSClipboardItem.h" #include "JSCloseEvent.h" #include "JSCommonJSExtensions.h" #include "streams/JSCountQueuingStrategy.h" @@ -1302,6 +1305,9 @@ WEBCORE_GENERATED_CONSTRUCTOR_GETTER(AbortController); WEBCORE_GENERATED_CONSTRUCTOR_GETTER(AbortSignal); WEBCORE_GENERATED_CONSTRUCTOR_GETTER(BroadcastChannel); WEBCORE_GENERATED_CONSTRUCTOR_GETTER(ByteLengthQueuingStrategy) +WEBCORE_GENERATED_CONSTRUCTOR_GETTER(Clipboard); +WEBCORE_GENERATED_CONSTRUCTOR_GETTER(ClipboardEvent); +WEBCORE_GENERATED_CONSTRUCTOR_GETTER(ClipboardItem); WEBCORE_GENERATED_CONSTRUCTOR_GETTER(CloseEvent); WEBCORE_GENERATED_CONSTRUCTOR_GETTER(CompressionStream); WEBCORE_GENERATED_CONSTRUCTOR_GETTER(CountQueuingStrategy) @@ -1842,6 +1848,14 @@ JSC_DEFINE_HOST_FUNCTION(functionNavigatorGetHardwareConcurrency, (JSC::JSGlobal return JSValue::encode(JSC::jsNumber(WTF::numberOfProcessorCores())); } +// `navigator.clipboard`: the spec's lazy `[SameObject]` singleton, a native +// `JSClipboard` wrapping the per-global `WebCore::Clipboard` EventTarget. +JSC_DEFINE_HOST_FUNCTION(functionNavigatorGetClipboard, (JSC::JSGlobalObject * globalObject, JSC::CallFrame*)) +{ + auto* global = defaultGlobalObject(globalObject); + return JSValue::encode(global->m_clipboardInstance.getInitializedOnMainThread(global)); +} + JSC_DECLARE_HOST_FUNCTION(makeGetterTypeErrorForBuiltins); JSC_DECLARE_HOST_FUNCTION(makeDOMExceptionForBuiltins); JSC_DECLARE_HOST_FUNCTION(isAbortSignal); @@ -2522,15 +2536,23 @@ void GlobalObject::finishCreation(VM& vm) init.set(ErrorCodeCache::create(init.vm, structure)); } }, + { OBJECT_OFFSETOF(GlobalObject, m_clipboardInstance), [](const LazyProperty::Initializer& init) { + auto* globalObject = uncheckedDowncast(init.owner); + // The wrapper (cached by the DOM wrapper map) owns the impl. + auto clipboard = WebCore::Clipboard::create(globalObject->scriptExecutionContext()); + init.set(WebCore::toJS(init.owner, globalObject, clipboard.get()).getObject()); + } }, { OBJECT_OFFSETOF(GlobalObject, m_navigatorObject), [](const LazyProperty::Initializer& init) { JSC::JSGlobalObject* globalObject = init.owner; unsigned accessorAttributes = PropertyAttribute::Accessor | 0; - JSC::JSObject* obj = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 4); + JSC::JSObject* obj = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 5); obj->putDirectNativeIntrinsicGetter(init.vm, globalObject, JSC::Identifier::fromString(init.vm, "userAgent"_s), functionNavigatorGetUserAgent, JSC::NoIntrinsic, accessorAttributes); obj->putDirectNativeIntrinsicGetter(init.vm, globalObject, JSC::Identifier::fromString(init.vm, "platform"_s), functionNavigatorGetPlatform, JSC::NoIntrinsic, accessorAttributes); obj->putDirectNativeIntrinsicGetter(init.vm, globalObject, JSC::Identifier::fromString(init.vm, "hardwareConcurrency"_s), functionNavigatorGetHardwareConcurrency, JSC::NoIntrinsic, accessorAttributes); + // https://w3c.github.io/clipboard-apis/#navigator-interface + obj->putDirectNativeIntrinsicGetter(init.vm, globalObject, JSC::Identifier::fromString(init.vm, "clipboard"_s), functionNavigatorGetClipboard, JSC::NoIntrinsic, accessorAttributes); obj->putDirect(init.vm, init.vm.propertyNames->toStringTagSymbol, jsNontrivialString(init.vm, "Navigator"_s), PropertyAttribute::DontEnum | PropertyAttribute::ReadOnly); diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 3d4eb50b199a..1e2d6c6436d1 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -678,6 +678,7 @@ class GlobalObject : public Bun::GlobalScope { V(public, LazyPropertyOfGlobalObject, m_bunObject) \ V(public, LazyPropertyOfGlobalObject, m_cryptoObject) \ V(public, LazyPropertyOfGlobalObject, m_navigatorObject) \ + V(public, LazyPropertyOfGlobalObject, m_clipboardInstance) \ V(public, LazyPropertyOfGlobalObject, m_performanceObject) \ V(public, LazyPropertyOfGlobalObject, m_processObject) \ V(public, LazyPropertyOfGlobalObject, m_lazyStackCustomGetterSetter) \ diff --git a/src/jsc/bindings/ZigGlobalObject.lut.txt b/src/jsc/bindings/ZigGlobalObject.lut.txt index 58770d6ecbb3..d2769358e94a 100644 --- a/src/jsc/bindings/ZigGlobalObject.lut.txt +++ b/src/jsc/bindings/ZigGlobalObject.lut.txt @@ -47,6 +47,9 @@ AbortSignal AbortSignalConstructorCallback PropertyCallback BroadcastChannel BroadcastChannelConstructorCallback PropertyCallback ByteLengthQueuingStrategy ByteLengthQueuingStrategyConstructorCallback DontEnum|PropertyCallback + Clipboard ClipboardConstructorCallback PropertyCallback + ClipboardEvent ClipboardEventConstructorCallback PropertyCallback + ClipboardItem ClipboardItemConstructorCallback PropertyCallback CloseEvent CloseEventConstructorCallback PropertyCallback CompressionStream CompressionStreamConstructorCallback PropertyCallback CountQueuingStrategy CountQueuingStrategyConstructorCallback DontEnum|PropertyCallback diff --git a/src/jsc/bindings/blob.cpp b/src/jsc/bindings/blob.cpp index 798b867d345d..0704e7522879 100644 --- a/src/jsc/bindings/blob.cpp +++ b/src/jsc/bindings/blob.cpp @@ -1,11 +1,26 @@ #include "blob.h" +#include "BunString.h" #include "ZigGeneratedClasses.h" extern "C" JSC::EncodedJSValue SYSV_ABI Blob__create(JSC::JSGlobalObject* globalObject, void* impl); extern "C" void Blob__setAsFile(void* impl, const BunString* filename); +extern "C" void* Blob__fromBytesWithType(JSC::JSGlobalObject*, const uint8_t* ptr, size_t len, const char* mime); +extern "C" void* Blob__fromBytesWithNormalizedType(JSC::JSGlobalObject*, const uint8_t* ptr, size_t len, const uint8_t* mime, size_t mimeLength); namespace WebCore { +RefPtr Blob::create(std::span bytes, const String& type, JSC::JSGlobalObject* globalThis) +{ + auto mime = Bun::UTF8View::tryCreate(type); + std::span mimeBytes = mime ? mime->bytes() : std::span {}; + return createAdopted(Blob__fromBytesWithNormalizedType(globalThis, bytes.data(), bytes.size(), mimeBytes.data(), mimeBytes.size())); +} + +RefPtr Blob::createWithExactType(std::span bytes, ASCIILiteral type, JSC::JSGlobalObject* globalThis) +{ + return createAdopted(Blob__fromBytesWithType(globalThis, bytes.data(), bytes.size(), type.characters())); +} + JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, WebCore::Blob& impl) { BunString filename = Bun::toString(impl.fileName()); diff --git a/src/jsc/bindings/blob.h b/src/jsc/bindings/blob.h index 631861a37ae6..3f1c3b87c9fd 100644 --- a/src/jsc/bindings/blob.h +++ b/src/jsc/bindings/blob.h @@ -63,6 +63,10 @@ class Blob : public RefCounted { return createAdopted(Blob__dupe(ptr)); } + // `new Blob([bytes], { type })`. + static RefPtr create(std::span bytes, const String& type, JSC::JSGlobalObject* globalThis); + static RefPtr createWithExactType(std::span bytes, ASCIILiteral type, JSC::JSGlobalObject* globalThis); + String fileName() { return m_fileName; diff --git a/src/jsc/bindings/image_coregraphics_shim.cpp b/src/jsc/bindings/image_coregraphics_shim.cpp index 26874e691869..7eb42da23151 100644 --- a/src/jsc/bindings/image_coregraphics_shim.cpp +++ b/src/jsc/bindings/image_coregraphics_shim.cpp @@ -26,6 +26,7 @@ #include #include #include +#include namespace { @@ -70,6 +71,7 @@ struct Syms { // CoreFoundation void (*CFRelease)(CFRef); CFRef (*CFDataCreateWithBytesNoCopy)(CFRef, const uint8_t*, long, CFRef); + CFRef (*CFDataCreate)(CFRef, const uint8_t*, long); CFRef (*CFDataCreateMutable)(CFRef, long); long (*CFDataGetLength)(CFRef); const uint8_t* (*CFDataGetBytePtr)(CFRef); @@ -124,6 +126,7 @@ constexpr struct { SYM(objc_msgSend), SYM(CFRelease), SYM(CFDataCreateWithBytesNoCopy), + SYM(CFDataCreate), SYM(CFDataCreateMutable), SYM(CFDataGetLength), SYM(CFDataGetBytePtr), @@ -267,6 +270,43 @@ inline CFRef generalPasteboard(const Syms* s) return msg(s, cls, s->sel_registerName("generalPasteboard")); } +// NSPasteboard is not thread-safe; taken before each entry point's autorelease pool. +Lock pasteboardLock; + +// Autoreleased; null when the pasteboard has no `uti` representation. +inline CFRef dataForType(const Syms* s, CFRef pb, const char* uti) +{ + CFRef type = s->CFStringCreateWithCString(nullptr, uti, kBunCFStringEncodingUTF8); + if (!type) return nullptr; + CFRef data = msg(s, pb, s->sel_registerName("dataForType:"), type); + s->CFRelease(type); + return data; +} + +// A retained PNG encoding of the first image in `data`, or null. +inline CFRef pngFromImageData(const Syms* s, CFRef data) +{ + CFRef source = s->CGImageSourceCreateWithData(data, nullptr); + if (!source) return nullptr; + CFRef image = s->CGImageSourceCreateImageAtIndex(source, 0, nullptr); + s->CFRelease(source); + if (!image) return nullptr; + CFRef png = nullptr; + CFRef type = s->CFStringCreateWithCString(nullptr, "public.png", kBunCFStringEncodingUTF8); + CFRef sink = s->CFDataCreateMutable(nullptr, 0); + CFRef destination = type && sink ? s->CGImageDestinationCreateWithData(sink, type, 1, nullptr) : nullptr; + if (destination) { + s->CGImageDestinationAddImage(destination, image, nullptr); + if (s->CGImageDestinationFinalize(destination)) + png = std::exchange(sink, nullptr); + s->CFRelease(destination); + } + if (sink) s->CFRelease(sink); + if (type) s->CFRelease(type); + s->CGImageRelease(image); + return png; +} + } // namespace extern "C" { @@ -276,7 +316,8 @@ enum : int32_t { CG_OK = 0, CG_UNAVAILABLE = 1, CG_DECODE_FAILED = 2, CG_ENCODE_FAILED = 3, - CG_TOO_MANY_PIXELS = 4 }; + CG_TOO_MANY_PIXELS = 4, + CG_CLIPBOARD_CHANGED = 5 }; // Decode `bytes[0..len)` into a caller-allocated RGBA8 buffer. // Two-phase: pass `out=nullptr` to get dimensions; then call again with a @@ -525,9 +566,9 @@ int32_t bun_coregraphics_reflect(const uint8_t* src, uint32_t w, uint32_t h, // AppKit to the dlopen list — `NSPasteboard` is the only symbol we need from // it, and AppKit is already loaded in any GUI process. We never decode here: // the pasteboard hands back a container (PNG, TIFF, HEIC, …) and Bun.Image's -// regular decode path handles it. NSPasteboard is documented as main-thread -// safe to *read*; we still call it on the JS thread (via the static -// `fromClipboard` accessor), not the WorkPool. +// regular decode path handles it. Called on the JS thread (via the static +// `fromClipboard` accessor), so it waits for any `navigator.clipboard` +// operation holding `pasteboardLock`. // // Two-phase like encode: `out=nullptr` → probe (returns length, 0 = no image), // stashes the matched NSData in a thread-local; second call copies and @@ -538,9 +579,9 @@ int32_t bun_coregraphics_clipboard(uint8_t* out, size_t* out_len, int32_t probe_ { auto s = load(); if (!s) return CG_UNAVAILABLE; - Pool pool(s); thread_local CFRef pending = nullptr; + // The second phase only touches the NSData retained by the first. if (out && pending) { long n = s->CFDataGetLength(pending); std::memcpy(out, s->CFDataGetBytePtr(pending), static_cast(n)); @@ -554,6 +595,8 @@ int32_t bun_coregraphics_clipboard(uint8_t* out, size_t* out_len, int32_t probe_ pending = nullptr; } + Locker locker { pasteboardLock }; + Pool pool(s); CFRef pb = generalPasteboard(s); if (!pb) return CG_UNAVAILABLE; CFRef dataForType = s->sel_registerName("dataForType:"); @@ -585,11 +628,127 @@ int64_t bun_coregraphics_clipboard_change_count() { auto s = load(); if (!s) return -1; + Locker locker { pasteboardLock }; Pool pool(s); CFRef pb = generalPasteboard(s); return pb ? msg(s, pb, s->sel_registerName("changeCount")) : -1; } +// ── NSPasteboard reader / writer for `navigator.clipboard` ───────────────── +// A retained NSData per type (null when absent), all under one lock. +int32_t bun_coregraphics_clipboard_read_types(const char* const* utis, size_t count, void** out_datas, size_t* out_lens) +{ + for (size_t i = 0; i < count; i++) { + out_datas[i] = nullptr; + out_lens[i] = 0; + } + auto s = load(); + if (!s) return CG_UNAVAILABLE; + // A TIFF-only image is converted, as in WebKit's reader, once the lock is released. + size_t tiffIndex = count; + { + Locker locker { pasteboardLock }; + Pool pool(s); + CFRef pb = generalPasteboard(s); + if (!pb) return CG_UNAVAILABLE; + CFRef changeCount = s->sel_registerName("changeCount"); + CFRef retain = s->sel_registerName("retain"); + long generation = msg(s, pb, changeCount); + for (size_t i = 0; i < count; i++) { + CFRef data = dataForType(s, pb, utis[i]); + if (!data && !std::strcmp(utis[i], "public.png")) { + data = dataForType(s, pb, "public.tiff"); + if (data) tiffIndex = i; + } + if (!data) continue; + out_datas[i] = msg(s, data, retain); + long n = out_datas[i] ? s->CFDataGetLength(out_datas[i]) : 0; + out_lens[i] = n > 0 ? static_cast(n) : 0; + } + // Another process may have written meanwhile. + if (msg(s, pb, changeCount) != generation) { + for (size_t i = 0; i < count; i++) { + if (out_datas[i]) s->CFRelease(out_datas[i]); + out_datas[i] = nullptr; + out_lens[i] = 0; + } + return CG_CLIPBOARD_CHANGED; + } + } + if (tiffIndex < count && out_datas[tiffIndex]) { + Pool pool(s); + CFRef tiff = out_datas[tiffIndex]; + CFRef png = pngFromImageData(s, tiff); + s->CFRelease(tiff); + out_datas[tiffIndex] = png; + long n = png ? s->CFDataGetLength(png) : 0; + out_lens[tiffIndex] = n > 0 ? static_cast(n) : 0; + } + return CG_OK; +} + +// Copies a `bun_coregraphics_clipboard_read_types` handle into `out` (which +// must have room for the length that call reported) and releases it. +int32_t bun_coregraphics_clipboard_take_data(void* data, uint8_t* out) +{ + auto s = load(); + // Never report success over an unwritten buffer: the caller would hand a run + // of zeroes to JS as the clipboard's contents. + if (!s) return CG_UNAVAILABLE; + long n = s->CFDataGetLength(data); + if (n > 0) std::memcpy(out, s->CFDataGetBytePtr(data), static_cast(n)); + s->CFRelease(data); + return CG_OK; +} + +// One `clearContents` then one `setData:forType:` per representation — the +// documented multi-flavour write. Every CFData/CFString is created before the +// destructive clear, so a failed write never costs the previous contents. +int32_t bun_coregraphics_clipboard_write_types(const char* const* utis, const uint8_t* const* datas, const size_t* lens, size_t count) +{ + auto s = load(); + if (!s) return CG_UNAVAILABLE; + Locker locker { pasteboardLock }; + Pool pool(s); + CFRef pb = generalPasteboard(s); + if (!pb) return CG_UNAVAILABLE; + // The Rust caller never passes 0 or more than the 3 supported MIME types. + enum { kMaxRepresentations = 8 }; + if (count == 0 || count > kMaxRepresentations) return CG_ENCODE_FAILED; + // CFData wants a real base pointer even for length 0 (Rust hands us a + // dangling sentinel for an empty slice). + static const uint8_t kEmpty = 0; + CFRef cfData[kMaxRepresentations] = {}; + CFRef cfType[kMaxRepresentations] = {}; + int32_t status = CG_OK; + for (size_t i = 0; i < count; i++) { + cfData[i] = s->CFDataCreate(nullptr, lens[i] ? datas[i] : &kEmpty, static_cast(lens[i])); + cfType[i] = cfData[i] ? s->CFStringCreateWithCString(nullptr, utis[i], kBunCFStringEncodingUTF8) : nullptr; + if (!cfType[i]) { + status = CG_ENCODE_FAILED; + break; + } + } + if (status == CG_OK) { + msg(s, pb, s->sel_registerName("clearContents")); + for (size_t i = 0; i < count; i++) { + // BOOL is `signed char` on x86_64 and `bool` on arm64; both return + // in the low byte, so read it as `signed char`. + if (msg(s, pb, s->sel_registerName("setData:forType:"), cfData[i], cfType[i]) == 0) { + status = CG_ENCODE_FAILED; + // Never leave a partial item behind. + msg(s, pb, s->sel_registerName("clearContents")); + break; + } + } + } + for (size_t i = 0; i < count; i++) { + if (cfData[i]) s->CFRelease(cfData[i]); + if (cfType[i]) s->CFRelease(cfType[i]); + } + return status; +} + } // extern "C" #else diff --git a/src/jsc/bindings/webcore/Clipboard.cpp b/src/jsc/bindings/webcore/Clipboard.cpp new file mode 100644 index 000000000000..bd41adcc8c01 --- /dev/null +++ b/src/jsc/bindings/webcore/Clipboard.cpp @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2019-2022 Apple Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS + * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "config.h" +#include "Clipboard.h" + +#include "ClipboardBlob.h" +#include "ClipboardEvent.h" +#include "EventNames.h" +#include "JSClipboardItem.h" +#include "JSDOMConvertSequences.h" +#include "JSDOMConvertStrings.h" +#include "JSDOMPromiseDeferred.h" +#include "helpers.h" +#include +#include +#include + +namespace WebCore { + +WTF_MAKE_TZONE_ALLOCATED_IMPL(Clipboard); + +Clipboard::Clipboard(ScriptExecutionContext* context) + : ContextDestructionObserver(context) +{ +} + +Clipboard::~Clipboard() +{ + if (RefPtr itemWriter = std::exchange(m_activeItemWriter, nullptr)) + itemWriter->invalidate(); +} + +void Clipboard::fireClipboardEvent(const AtomString& type) +{ + dispatchEvent(ClipboardEvent::create(type, EventInit {}, Event::IsTrusted::Yes)); +} + +// Null when the text does not fit a string. `String::fromUTF8ReplacingInvalidSequences` +// aborts the process on such text, and also on 1 GiB of text that is not all ASCII. +static String textFromClipboard(std::span utf8) +{ + if (utf8.empty()) + return emptyString(); + // A string holds UTF-16 units, and UTF-8 has at least one byte for each. + size_t maxLength = std::min(Bun__stringSyntheticAllocationLimit, static_cast(String::MaxLength)); + if (utf8.size() > maxLength && simdutf::utf16_length_from_utf8(reinterpret_cast(utf8.data()), utf8.size()) > maxLength) [[unlikely]] + return {}; + return Zig::convertUTF8ToString(utf8); +} + +ClipboardCompletion Clipboard::writeCompletion(Ref&& promise) +{ + return [promise = WTF::move(promise), protectedThis = Ref { *this }](JSC::JSGlobalObject&, std::span, const String& failureMessage) { + if (!failureMessage.isNull()) { + promise->reject(ExceptionCode::NotAllowedError, failureMessage); + return; + } + promise->resolve(); + protectedThis->fireClipboardEvent(eventNames().copyEvent); + }; +} + +void Clipboard::readText(Ref&& promise) +{ + auto* globalObject = promise->globalObject(); + if (!globalObject) { + promise->reject(ExceptionCode::InvalidStateError); + return; + } + + scheduleClipboardReadText(*globalObject, [promise = WTF::move(promise), protectedThis = Ref { *this }](JSC::JSGlobalObject&, std::span representations, const String& failureMessage) { + if (!failureMessage.isNull()) { + promise->reject(ExceptionCode::NotAllowedError, failureMessage); + return; + } + String text = emptyString(); + if (!representations.empty()) + text = textFromClipboard({ representations[0].bytes, representations[0].length }); + if (text.isNull()) [[unlikely]] { + promise->reject(ExceptionCode::NotAllowedError, "The text on the clipboard is too large to read as a string."_s); + return; + } + promise->resolve(text); + protectedThis->fireClipboardEvent(eventNames().pasteEvent); + }); +} + +void Clipboard::writeText(const String& data, Ref&& promise) +{ + auto* globalObject = promise->globalObject(); + if (!globalObject) { + promise->reject(ExceptionCode::InvalidStateError); + return; + } + + if (RefPtr previousItemWriter = std::exchange(m_activeItemWriter, nullptr)) + previousItemWriter->invalidate(); + + scheduleClipboardWriteText(*globalObject, data, writeCompletion(WTF::move(promise))); +} + +void Clipboard::read(Ref&& promise) +{ + auto* globalObject = promise->globalObject(); + if (!globalObject) { + promise->reject(ExceptionCode::InvalidStateError); + return; + } + + scheduleClipboardRead(*globalObject, [promise = WTF::move(promise), protectedThis = Ref { *this }](JSC::JSGlobalObject& globalObject, std::span representations, const String& failureMessage) { + if (!failureMessage.isNull()) { + promise->reject(ExceptionCode::NotAllowedError, failureMessage); + return; + } + + Vector> items; + if (!representations.empty()) { + ClipboardItemData data; + data.reserveInitialCapacity(representations.size()); + for (auto& representation : representations) { + auto type = clipboardMIMETypeString(representation.type); + data.append({ String(type), Blob::createWithExactType({ representation.bytes, representation.length }, type, &globalObject).releaseNonNull() }); + } + items.append(ClipboardItem::create(WTF::move(data))); + } + + promise->resolve>>(items); + protectedThis->fireClipboardEvent(eventNames().pasteEvent); + }); +} + +void Clipboard::write(const Vector>& data, Ref&& promise) +{ + if (RefPtr previousItemWriter = std::exchange(m_activeItemWriter, nullptr)) + previousItemWriter->invalidate(); + + // https://w3c.github.io/clipboard-apis/#dom-clipboard-write: the clipboard is written per item. + if (data.isEmpty()) { + promise->resolve(); + return; + } + + if (data.size() > 1) { + promise->reject(ExceptionCode::NotAllowedError, "Writing multiple ClipboardItems is not supported."_s); + return; + } + + Ref itemWriter = ItemWriter::create(*this, Ref { *data[0] }, WTF::move(promise)); + m_activeItemWriter = itemWriter.copyRef(); + itemWriter->write(); +} + +Clipboard::ItemWriter::ItemWriter(Clipboard& clipboard, Ref&& item, Ref&& promise) + : m_clipboard(clipboard) + , m_item(WTF::move(item)) + , m_promise(WTF::move(promise)) +{ +} + +Clipboard::ItemWriter::~ItemWriter() = default; + +void Clipboard::ItemWriter::write() +{ + Ref item = *m_item; + Vector essences; + for (auto& type : item->types()) { + auto essence = ClipboardItem::parseMIMETypeEssence(type); + if (!clipboardMIMETypeFromEssence(essence)) { + reject(ExceptionCode::NotAllowedError, makeString("The type \""_s, type, "\" is not supported on this platform."_s)); + return; + } + // Platform formats carry no parameters, so the second would overwrite the first. + if (essences.contains(essence)) { + reject(ExceptionCode::NotAllowedError, makeString("Writing two \""_s, essence, "\" representations is not supported."_s)); + return; + } + essences.append(WTF::move(essence)); + } + if (!clipboardWritesMultipleRepresentations && essences.size() > 1) { + reject(ExceptionCode::NotAllowedError, "Writing more than one representation per item is not supported on this platform."_s); + return; + } + + item->collectDataForWriting([protectedThis = Ref { *this }](std::optional data, JSC::JSValue failureReason) { + if (!data) { + protectedThis->rejectWithValue(failureReason); + return; + } + protectedThis->didCollect(WTF::move(*data)); + }); +} + +void Clipboard::ItemWriter::didCollect(ClipboardItemData&& data) +{ + RefPtr promise = m_promise; + if (!promise) + return; + // Collected: nothing on the item is armed any more, so a later write of it is independent. + m_item = nullptr; + m_data = WTF::move(data); + + Vector pendingReads; + for (size_t index = 0; index < m_data.size(); ++index) { + if (clipboardBlobNeedsToReadFile(m_data[index].value)) + pendingReads.append(index); + } + if (pendingReads.isEmpty()) { + schedulePlatformWrite(); + return; + } + + auto* globalObject = promise->globalObject(); + if (!globalObject) { + reject(ExceptionCode::InvalidStateError, "The clipboard is no longer available."_s); + return; + } + m_pendingBlobReads = pendingReads.size(); + for (auto index : pendingReads) { + // A synchronous failure has already rejected. + if (!m_promise) + return; + clipboardBlobReadAsync(*globalObject, m_data[index].value, [protectedThis = Ref { *this }, index](std::span bytes, const String& failureMessage) { + protectedThis->didReadBlob(index, bytes, failureMessage); + }); + } +} + +void Clipboard::ItemWriter::didReadBlob(size_t index, std::span bytes, const String& failureMessage) +{ + RefPtr promise = m_promise; + if (!promise) + return; + if (!failureMessage.isNull()) { + reject(ExceptionCode::NotAllowedError, failureMessage); + return; + } + auto* globalObject = promise->globalObject(); + if (!globalObject) { + reject(ExceptionCode::InvalidStateError, "The clipboard is no longer available."_s); + return; + } + + m_data[index].value = Blob::create(bytes, m_data[index].key, globalObject).releaseNonNull(); + ASSERT(m_pendingBlobReads); + if (!--m_pendingBlobReads) + schedulePlatformWrite(); +} + +void Clipboard::ItemWriter::schedulePlatformWrite() +{ + Ref promise = m_promise.releaseNonNull(); + RefPtr clipboard = m_clipboard.get(); + auto data = std::exchange(m_data, {}); + // Scheduled writes are not superseded; they land in whatever order the platform runs them. + detach(); + + auto* globalObject = promise->globalObject(); + if (!clipboard || !globalObject) { + promise->reject(ExceptionCode::InvalidStateError, "The clipboard is no longer available."_s); + return; + } + scheduleClipboardWrite(*globalObject, data, clipboard->writeCompletion(WTF::move(promise))); +} + +void Clipboard::ItemWriter::reject(ExceptionCode code, const String& message) +{ + if (RefPtr promise = std::exchange(m_promise, nullptr)) + promise->reject(code, message); + detach(); +} + +void Clipboard::ItemWriter::rejectWithValue(JSC::JSValue failureReason) +{ + if (RefPtr promise = std::exchange(m_promise, nullptr)) { + if (failureReason) + promise->reject(failureReason); + else + promise->reject(ExceptionCode::NotAllowedError, "A ClipboardItem representation could not be read."_s); + } + detach(); +} + +void Clipboard::ItemWriter::invalidate() +{ + if (RefPtr promise = std::exchange(m_promise, nullptr)) + promise->reject(ExceptionCode::AbortError); + // The clipboard may be mid-destruction; detach() must not reference it. + m_clipboard = nullptr; + detach(); +} + +// Callers hold their own reference: clearing the clipboard's may drop the last other one. +void Clipboard::ItemWriter::detach() +{ + m_data = {}; + // Retiring the collect re-enters rejectWithValue(), which finds m_item already cleared. + if (RefPtr item = std::exchange(m_item, nullptr)) + item->cancelDataCollection(); + if (RefPtr clipboard = m_clipboard.get(); clipboard && clipboard->m_activeItemWriter == this) + clipboard->m_activeItemWriter = nullptr; + m_clipboard = nullptr; +} + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/Clipboard.h b/src/jsc/bindings/webcore/Clipboard.h new file mode 100644 index 000000000000..bbd98b9e06ac --- /dev/null +++ b/src/jsc/bindings/webcore/Clipboard.h @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2019 Apple Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS + * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "root.h" +#include "ClipboardItem.h" +#include "ClipboardPlatform.h" +#include "ContextDestructionObserver.h" +#include "EventTarget.h" +#include "ExceptionCode.h" +#include +#include +#include +#include +#include +#include + +namespace WebCore { + +class DeferredPromise; +class ScriptExecutionContext; + +// https://w3c.github.io/clipboard-apis/#clipboard-interface +class Clipboard final : public RefCounted, public ContextDestructionObserver, public EventTarget { + WTF_MAKE_TZONE_ALLOCATED(Clipboard); + +public: + static Ref create(ScriptExecutionContext* context) { return adoptRef(*new Clipboard(context)); } + ~Clipboard(); + + void readText(Ref&&); + void writeText(const String& data, Ref&&); + void read(Ref&&); + void write(const Vector>& data, Ref&&); + + ScriptExecutionContext* scriptExecutionContext() const final { return ContextDestructionObserver::scriptExecutionContext(); } + EventTargetInterface eventTargetInterface() const final { return ClipboardEventTargetInterfaceType; } + + void ref() const final { RefCounted::ref(); } + void deref() const final { RefCounted::deref(); } + USING_CAN_MAKE_WEAKPTR(EventTarget); + +private: + explicit Clipboard(ScriptExecutionContext*); + + void refEventTarget() final { ref(); } + void derefEventTarget() final { deref(); } + + // There is no document or focused element: successful operations fire at navigator.clipboard. + void fireClipboardEvent(const AtomString& type); + ClipboardCompletion writeCompletion(Ref&&); + + // Collects one item into Blobs, then schedules one platform write. + class ItemWriter : public RefCounted { + public: + static Ref create(Clipboard& clipboard, Ref&& item, Ref&& promise) + { + return adoptRef(*new ItemWriter(clipboard, WTF::move(item), WTF::move(promise))); + } + + ~ItemWriter(); + + void write(); + void invalidate(); + + private: + ItemWriter(Clipboard&, Ref&&, Ref&&); + + void didCollect(ClipboardItemData&&); + void didReadBlob(size_t index, std::span bytes, const String& failureMessage); + void schedulePlatformWrite(); + void reject(ExceptionCode, const String& message); + void rejectWithValue(JSC::JSValue failureReason); + void detach(); + + WeakPtr m_clipboard; + // The only strong owner of the item while it is collected. + RefPtr m_item; + RefPtr m_promise; + ClipboardItemData m_data; + unsigned m_pendingBlobReads { 0 }; + }; + + // The writer still collecting; a later write supersedes it. + RefPtr m_activeItemWriter; +}; + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/ClipboardBlob.cpp b/src/jsc/bindings/webcore/ClipboardBlob.cpp new file mode 100644 index 000000000000..92864dbc91eb --- /dev/null +++ b/src/jsc/bindings/webcore/ClipboardBlob.cpp @@ -0,0 +1,83 @@ +#include "config.h" +#include "ClipboardBlob.h" + +#include "BunString.h" +#include +#include + +namespace WebCore { + +// Implemented in src/runtime/webcore/Blob.rs. These take the impl, unlike +// blob.h's getters, which take the JS wrapper. +extern "C" void Blob__implGetSpan(BlobImpl*, const uint8_t** outPtr, size_t* outLength); +extern "C" bool Blob__implNeedsToReadFile(BlobImpl*); +extern "C" void Blob__implGetContentType(BlobImpl*, const uint8_t** outPtr, size_t* outLength); +extern "C" void Blob__implClearFile(BlobImpl*); +extern "C" void Blob__implSetContentType(BlobImpl*, const uint8_t* mime, size_t length); +extern "C" void Blob__implReadBytes(BlobImpl*, JSC::JSGlobalObject*, void* ctx, void (*callback)(void* ctx, const uint8_t* ptr, size_t length, const uint8_t* error, size_t errorLength)); +extern "C" JSC::EncodedJSValue SYSV_ABI Blob__create(JSC::JSGlobalObject*, void*); + +std::span clipboardBlobBytes(Blob& blob) +{ + const uint8_t* data = nullptr; + size_t size = 0; + Blob__implGetSpan(blob.impl(), &data, &size); + return { data, size }; +} + +bool clipboardBlobNeedsToReadFile(Blob& blob) +{ + return Blob__implNeedsToReadFile(blob.impl()); +} + +namespace { +struct ClipboardBlobReadContext { + ClipboardBlobReadCompletion completion; +}; +} + +static void clipboardBlobReadComplete(void* opaque, const uint8_t* bytes, size_t length, const uint8_t* error, size_t errorLength) +{ + std::unique_ptr context { static_cast(opaque) }; + String failureMessage; + if (error) + failureMessage = String::fromUTF8ReplacingInvalidSequences({ error, errorLength }); + context->completion({ bytes, length }, failureMessage); +} + +void clipboardBlobReadAsync(JSC::JSGlobalObject& globalObject, Blob& blob, ClipboardBlobReadCompletion&& completion) +{ + Blob__implReadBytes(blob.impl(), &globalObject, new ClipboardBlobReadContext { WTF::move(completion) }, clipboardBlobReadComplete); +} + +String clipboardBlobContentType(Blob& blob) +{ + const uint8_t* ptr = nullptr; + size_t length = 0; + Blob__implGetContentType(blob.impl(), &ptr, &length); + return String::fromUTF8({ ptr, length }); +} + +JSC::JSValue clipboardBlobToJS(JSC::JSGlobalObject* globalObject, Blob& blob, const String& type) +{ + // The dupe shares the store; blob.h's toJS(Blob&) would mark it a File. + auto* dupe = static_cast(Blob__dupe(blob.impl())); + Blob__implClearFile(dupe); + if (!clipboardBlobTypeMatches(clipboardBlobContentType(blob), type)) { + if (auto requested = Bun::UTF8View::tryCreate(type)) { + auto requestedBytes = requested->bytes(); + Blob__implSetContentType(dupe, requestedBytes.data(), requestedBytes.size()); + } + } + return JSC::JSValue::decode(Blob__create(globalObject, dupe)); +} + +bool clipboardBlobTypeMatches(const String& declared, const String& requested) +{ + if (declared == requested) + return true; + // Bun's Blob adds a charset to text types. + return declared.length() > requested.length() && declared[requested.length()] == ';' && declared.startsWith(requested); +} + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/ClipboardBlob.h b/src/jsc/bindings/webcore/ClipboardBlob.h new file mode 100644 index 000000000000..b675d8518a86 --- /dev/null +++ b/src/jsc/bindings/webcore/ClipboardBlob.h @@ -0,0 +1,34 @@ +#pragma once + +// Blob access the clipboard needs against the refcounted impl (blob.h's +// getters take a JS value). + +#include "root.h" +#include "blob.h" +#include +#include +#include + +namespace WebCore { + +// Empty for a file- or S3-backed Blob; check clipboardBlobNeedsToReadFile() first. +std::span clipboardBlobBytes(Blob&); +bool clipboardBlobNeedsToReadFile(Blob&); + +// `failureMessage` is null on success; the span does not outlive the call. +using ClipboardBlobReadCompletion = Function, const String& failureMessage)>; + +// Delivers the Blob's bytes (memory, file, S3) on the JS thread exactly once, +// synchronously when they are resident. +void clipboardBlobReadAsync(JSC::JSGlobalObject&, Blob&, ClipboardBlobReadCompletion&&); + +String clipboardBlobContentType(Blob&); + +// Whether `declared` satisfies a request for `requested`: equal, or `requested` plus parameters. +bool clipboardBlobTypeMatches(const String& declared, const String& requested); + +// getType()'s "a new Blob" (https://w3c.github.io/clipboard-apis/#dom-clipboarditem-gettype): +// a plain Blob sharing the bytes, reporting `type`. +JSC::JSValue clipboardBlobToJS(JSC::JSGlobalObject*, Blob&, const String& type); + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/ClipboardEvent.cpp b/src/jsc/bindings/webcore/ClipboardEvent.cpp new file mode 100644 index 000000000000..35da3e79bd74 --- /dev/null +++ b/src/jsc/bindings/webcore/ClipboardEvent.cpp @@ -0,0 +1,10 @@ +#include "root.h" +#include "ClipboardEvent.h" + +#include + +namespace WebCore { + +WTF_MAKE_TZONE_ALLOCATED_IMPL(ClipboardEvent); + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/ClipboardEvent.h b/src/jsc/bindings/webcore/ClipboardEvent.h new file mode 100644 index 000000000000..7de981bcdf66 --- /dev/null +++ b/src/jsc/bindings/webcore/ClipboardEvent.h @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2011 Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "root.h" +#include "Event.h" +#include "EventNames.h" + +namespace WebCore { + +// https://w3c.github.io/clipboard-apis/#clipboard-event-interfaces +// Bun has no DataTransfer, so the spec'd `clipboardData` member is not stored +// and the attribute is always null; the event is otherwise a plain Event. +class ClipboardEvent final : public Event { + WTF_MAKE_TZONE_ALLOCATED(ClipboardEvent); + +public: + // The spec's ClipboardEventInit only adds `clipboardData`, which Bun does + // not store (no DataTransfer), so a plain EventInit describes it fully. + static Ref create(const AtomString& type, const EventInit& initializer, IsTrusted isTrusted = IsTrusted::No) + { + return adoptRef(*new ClipboardEvent(type, initializer, isTrusted)); + } + +private: + ClipboardEvent(const AtomString& type, const EventInit& initializer, IsTrusted isTrusted) + : Event(ClipboardEventInterfaceType, type, initializer, isTrusted) + { + } +}; + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/ClipboardItem.cpp b/src/jsc/bindings/webcore/ClipboardItem.cpp new file mode 100644 index 000000000000..cb60095f0488 --- /dev/null +++ b/src/jsc/bindings/webcore/ClipboardItem.cpp @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2019 Apple Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS + * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "config.h" +#include "ClipboardItem.h" + +#include "BunString.h" +#include "ClipboardBlob.h" +#include "ClipboardPlatform.h" +#include "ExceptionCode.h" +#include "ExceptionOr.h" +#include "HTTPParsers.h" +#include "JSDOMPromise.h" +#include "JSDOMPromiseDeferred.h" +#include +#include +#include + +namespace WebCore { + +ClipboardItem::ClipboardItem(Vector>>&& promises, const Options& options) + : m_promises(WTF::move(promises)) + , m_presentationStyle(options.presentationStyle) +{ +} + +ClipboardItem::ClipboardItem(ClipboardItemData&& data) + : m_data(WTF::move(data)) +{ +} + +ClipboardItem::~ClipboardItem() = default; + +ExceptionOr> ClipboardItem::create(Vector>>&& items, const Options& options) +{ + // https://w3c.github.io/clipboard-apis/#dom-clipboarditem-clipboarditem + if (items.isEmpty()) + return Exception { ExceptionCode::TypeError, "ClipboardItem requires at least one representation"_s }; + + return adoptRef(*new ClipboardItem(WTF::move(items), options)); +} + +Ref ClipboardItem::create(ClipboardItemData&& data) +{ + return adoptRef(*new ClipboardItem(WTF::move(data))); +} + +Vector ClipboardItem::types() const +{ + if (!m_promises.isEmpty()) + return m_promises.map([](auto& entry) { return entry.key; }); + return m_data.map([](auto& entry) { return entry.key; }); +} + +// The exact serialization first, so same-essence entries stay reachable. +template +static size_t findType(const Entries& entries, const String& type) +{ + auto index = entries.findIf([&](auto& entry) { return entry.key == type; }); + if (index != notFound) + return index; + auto essence = ClipboardItem::parseMIMETypeEssence(type); + return entries.findIf([&](auto& entry) { return ClipboardItem::essenceMatches(entry.key, essence); }); +} + +void ClipboardItem::getType(const String& type, Ref&& promise) +{ + auto index = m_promises.isEmpty() ? findType(m_data, type) : findType(m_promises, type); + if (index == notFound) { + promise->reject(ExceptionCode::NotFoundError, makeString("The type \""_s, type, "\" was not found"_s)); + return; + } + + if (m_promises.isEmpty()) { + promise->resolveWithCallback([&](JSDOMGlobalObject& globalObject) { + return clipboardBlobToJS(&globalObject, m_data[index].value.get(), type); + }); + return; + } + + auto* promiseGlobalObject = m_promises[index].value->globalObject(); + auto* typePromise = dynamicDowncast(promise->promise()); + if (!promiseGlobalObject || !typePromise) { + promise->reject(ExceptionCode::InvalidStateError); + return; + } + auto scope = DECLARE_THROW_SCOPE(promiseGlobalObject->vm()); + // The coercion runs user JS, so it runs as a reaction of the representation + // with getType()'s promise as the derived one: JSC rejects that with what + // the coercion throws, or with the representation's own rejection. + auto registered = m_promises[index].value->whenFulfilled(*typePromise, [type](JSDOMGlobalObject& globalObject, JSC::JSValue value) -> JSC::JSValue { + auto throwScope = DECLARE_THROW_SCOPE(globalObject.vm()); + RefPtr blob = blobFromSettledValue(&globalObject, value, type); + if (throwScope.exception()) [[unlikely]] + return {}; + if (!blob) [[unlikely]] { + throwOutOfMemoryError(&globalObject, throwScope); + return {}; + } + throwScope.release(); + return clipboardBlobToJS(&globalObject, *blob, type); + }); + RETURN_IF_EXCEPTION(scope, void()); + if (registered == DOMPromise::IsCallbackRegistered::No) { + scope.release(); + promise->reject(ExceptionCode::InvalidStateError); + } +} + +bool ClipboardItem::supports(const String& type) +{ + return clipboardMIMETypeFromEssence(parseMIMETypeEssence(type)).has_value(); +} + +String ClipboardItem::parseMIMETypeEssence(const String& type) +{ + auto view = StringView(type).trim(isHTTPSpace); + size_t semicolon = view.find(';'); + if (semicolon != notFound) + view = view.left(semicolon).trim(isHTTPSpace); + size_t slash = view.find('/'); + if (slash == notFound) + return {}; + if (!isValidHTTPToken(view.left(slash)) || !isValidHTTPToken(view.substring(slash + 1))) + return {}; + return view.convertToASCIILowercase(); +} + +// https://mimesniff.spec.whatwg.org/#parse-a-mime-type step 11 onward and +// https://mimesniff.spec.whatwg.org/#serialize-a-mime-type +static void appendSerializedMIMEParameters(StringBuilder& result, StringView view, size_t position) +{ + auto isQuotedStringToken = [](char16_t c) { + return c == 0x09 || (c >= 0x20 && c <= 0x7E) || (c >= 0x80 && c <= 0xFF); + }; + Vector seenNames; + size_t length = view.length(); + while (position < length) { + while (position < length && isHTTPSpace(view[position])) + ++position; + size_t nameStart = position; + while (position < length && view[position] != ';' && view[position] != '=') + ++position; + String name = view.substring(nameStart, position - nameStart).convertToASCIILowercase(); + if (position >= length) + break; + if (view[position] == ';') { + ++position; + continue; + } + ++position; // '=' + String value; + bool quoted = position < length && view[position] == '"'; + if (quoted) { + ++position; + StringBuilder collected; + while (position < length && view[position] != '"') { + if (view[position] == '\\' && position + 1 < length) + ++position; + collected.append(view[position]); + ++position; + } + if (position < length) + ++position; // closing '"' + value = collected.toString(); + while (position < length && view[position] != ';') + ++position; + } else { + size_t valueStart = position; + while (position < length && view[position] != ';') + ++position; + auto raw = view.substring(valueStart, position - valueStart); + size_t end = raw.length(); + while (end && isHTTPSpace(raw[end - 1])) + --end; + value = raw.left(end).toString(); + } + if (position < length) + ++position; // ';' + if (name.isEmpty() || !isValidHTTPToken(name) || seenNames.contains(name)) + continue; + if (value.isEmpty() && !quoted) + continue; + bool valueValid = true; + for (char16_t c : StringView(value).codeUnits()) { + if (!isQuotedStringToken(c)) { + valueValid = false; + break; + } + } + if (!valueValid) + continue; + seenNames.append(name); + result.append(';', name, '='); + if (!value.isEmpty() && isValidHTTPToken(value)) + result.append(value); + else { + result.append('"'); + for (char16_t c : StringView(value).codeUnits()) { + if (c == '"' || c == '\\') + result.append('\\'); + result.append(c); + } + result.append('"'); + } + } +} + +String ClipboardItem::parseAndSerializeMIMEType(const String& type) +{ + String essence = parseMIMETypeEssence(type); + if (essence.isEmpty()) + return {}; + auto view = StringView(type).trim(isHTTPSpace); + size_t semicolon = view.find(';'); + if (semicolon == notFound) + return essence; + StringBuilder result; + result.append(essence); + appendSerializedMIMEParameters(result, view, semicolon + 1); + return result.toString(); +} + +bool ClipboardItem::essenceMatches(const String& serializedKey, const String& essence) +{ + size_t semicolon = serializedKey.find(';'); + if (semicolon == notFound) + return serializedKey == essence; + return StringView(serializedKey).left(semicolon) == essence; +} + +RefPtr ClipboardItem::blobFromSettledValue(JSC::JSGlobalObject* globalObject, JSC::JSValue value, const String& type) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (RefPtr blob = Blob::create(value)) { + // A file- or network-backed Blob has no bytes to rewrap: getType() + // hands it out lazily and write() reads it in before the transaction. + if (clipboardBlobTypeMatches(clipboardBlobContentType(*blob), type) || clipboardBlobNeedsToReadFile(*blob)) + return blob; + return Blob::create(clipboardBlobBytes(*blob), type, globalObject); + } + + auto string = value.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + auto utf8 = Bun::UTF8View::tryCreate(globalObject, scope, string); + if (!utf8) [[unlikely]] + return nullptr; + scope.release(); + return Blob::create(utf8->bytes(), type, globalObject); +} + +void ClipboardItem::collectDataForWriting(CollectCompletionHandler&& completion) +{ + // One item can be handed to two overlapping write()s; retire the older collect. + cancelDataCollection(); + if (m_promises.isEmpty()) { + completion(ClipboardItemData { m_data }, {}); + return; + } + + m_completionHandler = WTF::move(completion); + auto* globalObject = m_promises[0].value->globalObject(); + if (!globalObject) { + finishCollect(std::nullopt); + return; + } + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + m_collected = Vector>(m_promises.size()); + m_pendingCount = m_promises.size(); + auto generation = m_collectGeneration; + for (size_t index = 0; index < m_promises.size(); ++index) { + // The coercion runs user JS, so it runs as a reaction of the + // representation: what it throws rejects `coerced`, as a throwing `then` + // callback would, and nothing here catches. A collect the coercion made + // stale (it can start another write of this item) drops the result. + // WeakPtr: a strong back-edge would let a never-settling promise keep the item alive. + auto* coerced = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + auto registered = m_promises[index].value->whenFulfilled(*coerced, [weakThis = WeakPtr { *this }, generation, index, type = m_promises[index].key](JSDOMGlobalObject& globalObject, JSC::JSValue value) -> JSC::JSValue { + auto throwScope = DECLARE_THROW_SCOPE(globalObject.vm()); + RefPtr blob = blobFromSettledValue(&globalObject, value, type); + if (throwScope.exception()) [[unlikely]] + return {}; + if (!blob) [[unlikely]] { + throwOutOfMemoryError(&globalObject, throwScope); + return {}; + } + RefPtr protectedThis = weakThis.get(); + if (protectedThis && generation == protectedThis->m_collectGeneration) + protectedThis->m_collected[index] = WTF::move(blob); + return JSC::jsUndefined(); + }); + RETURN_IF_EXCEPTION(scope, void()); + if (registered == DOMPromise::IsCallbackRegistered::Yes) { + registered = DOMPromise::create(*globalObject, *coerced)->whenSettledWithResult([weakThis = WeakPtr { *this }, generation, index](JSDOMGlobalObject*, bool isFulfilled, JSC::JSValue result) { + RefPtr protectedThis = weakThis.get(); + if (protectedThis && generation == protectedThis->m_collectGeneration) + protectedThis->didSettle(index, isFulfilled, result); + }); + // With an exception pending the collect stays armed until the writer retires it. + RETURN_IF_EXCEPTION(scope, void()); + } + if (registered == DOMPromise::IsCallbackRegistered::No) { + scope.release(); + finishCollect(std::nullopt); + return; + } + } +} + +void ClipboardItem::cancelDataCollection() +{ + ++m_collectGeneration; + finishCollect(std::nullopt); +} + +// `result` is the representation's own rejection, or what its coercion threw. +void ClipboardItem::didSettle(size_t index, bool isFulfilled, JSC::JSValue result) +{ + if (!isFulfilled) { + finishCollect(std::nullopt, result); + return; + } + + ASSERT_UNUSED(index, m_collected[index]); + if (--m_pendingCount) + return; + ClipboardItemData data; + data.reserveInitialCapacity(m_promises.size()); + for (size_t i = 0; i < m_promises.size(); ++i) + data.append({ m_promises[i].key, m_collected[i].releaseNonNull() }); + finishCollect(WTF::move(data)); +} + +void ClipboardItem::finishCollect(std::optional&& data, JSC::JSValue failureReason) +{ + m_collected.clear(); + m_pendingCount = 0; + if (auto completion = std::exchange(m_completionHandler, {})) + completion(WTF::move(data), failureReason); +} + +size_t ClipboardItem::memoryCost() const +{ + size_t cost = 0; + for (auto& entry : m_data) + cost += entry.value->memoryCost(); + return cost; +} + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/ClipboardItem.h b/src/jsc/bindings/webcore/ClipboardItem.h new file mode 100644 index 000000000000..b0280f302dc9 --- /dev/null +++ b/src/jsc/bindings/webcore/ClipboardItem.h @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2019 Apple Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS + * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "root.h" +#include "blob.h" +#include +#include +#include +#include +#include +#include + +namespace WebCore { + +class DeferredPromise; +class DOMPromise; +template class ExceptionOr; + +// One item's representations, keyed by serialized MIME type. +using ClipboardItemData = Vector>>; + +// https://w3c.github.io/clipboard-apis/#clipboarditem +class ClipboardItem : public RefCountedAndCanMakeWeakPtr { +public: + ~ClipboardItem(); + + enum class PresentationStyle : uint8_t { Unspecified, + Inline, + Attachment }; + + struct Options { + PresentationStyle presentationStyle { PresentationStyle::Unspecified }; + }; + + static ExceptionOr> create(Vector>>&&, const Options&); + static Ref create(ClipboardItemData&&); + + // WebIDL `(DOMString or Blob)`: a Blob declaring `type` passes through, + // another Blob is rewrapped, anything else is ToString'd. Null with an + // exception pending when the coercion throws. + static RefPtr blobFromSettledValue(JSC::JSGlobalObject*, JSC::JSValue, const String& type); + + Vector types() const; + void getType(const String&, Ref&&); + static bool supports(const String& type); + + // The lowercased mimesniff essence (`type/subtype`), or empty when `type` does not parse. + static String parseMIMETypeEssence(const String&); + // mimesniff parse + serialize, parameters kept; empty on failure. + static String parseAndSerializeMIMEType(const String&); + static bool essenceMatches(const String& serializedKey, const String& essence); + + // `failureReason` is the representation's own rejection or coercion error, if any. + using CollectCompletionHandler = CompletionHandler, JSC::JSValue failureReason)>; + void collectDataForWriting(CollectCompletionHandler&&); + void cancelDataCollection(); + + PresentationStyle presentationStyle() const { return m_presentationStyle; } + size_t memoryCost() const; + +private: + ClipboardItem(Vector>>&&, const Options&); + explicit ClipboardItem(ClipboardItemData&&); + + void didSettle(size_t index, bool isFulfilled, JSC::JSValue); + void finishCollect(std::optional&&, JSC::JSValue failureReason = {}); + + // Constructed items hold the caller's promises; read() items hold the platform's Blobs. + Vector>> m_promises; + ClipboardItemData m_data; + PresentationStyle m_presentationStyle { PresentationStyle::Unspecified }; + + CollectCompletionHandler m_completionHandler; + Vector> m_collected; + size_t m_pendingCount { 0 }; + // Stamps each collect so a settle callback left over from a retired one is ignored. + unsigned m_collectGeneration { 0 }; +}; + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/ClipboardPlatform.cpp b/src/jsc/bindings/webcore/ClipboardPlatform.cpp new file mode 100644 index 000000000000..8c3bf9946430 --- /dev/null +++ b/src/jsc/bindings/webcore/ClipboardPlatform.cpp @@ -0,0 +1,112 @@ +#include "config.h" +#include "ClipboardPlatform.h" + +#include "BunString.h" +#include "ClipboardBlob.h" +#include "ZigGlobalObject.h" +#include +#include + +namespace WebCore { + +// Handed to the backend job, which completes or releases it on the JS thread. +struct ClipboardRequest { + ClipboardCompletion completion; +}; + +extern "C" bool Bun__Clipboard__scheduleReadText(JSC::JSGlobalObject*, ClipboardRequest*); +extern "C" bool Bun__Clipboard__scheduleRead(JSC::JSGlobalObject*, ClipboardRequest*); +// Copies every byte range before returning. +extern "C" bool Bun__Clipboard__scheduleWrite(JSC::JSGlobalObject*, ClipboardRequest*, const ClipboardRepresentation*, size_t count); + +static ClipboardRequest* createRequest(ClipboardCompletion&& completion) +{ + return new ClipboardRequest { WTF::move(completion) }; +} + +std::optional clipboardMIMETypeFromEssence(StringView essence) +{ + if (essence == "text/plain"_s) + return ClipboardMIMEType::TextPlain; + if (essence == "text/html"_s) + return ClipboardMIMEType::TextHtml; + if (essence == "image/png"_s) + return ClipboardMIMEType::ImagePng; + return std::nullopt; +} + +ASCIILiteral clipboardMIMETypeString(ClipboardMIMEType type) +{ + switch (type) { + case ClipboardMIMEType::TextPlain: + return "text/plain"_s; + case ClipboardMIMEType::TextHtml: + return "text/html"_s; + case ClipboardMIMEType::ImagePng: + return "image/png"_s; + } + RELEASE_ASSERT_NOT_REACHED(); +} + +void scheduleClipboardReadText(JSC::JSGlobalObject& globalObject, ClipboardCompletion&& completion) +{ + Bun__Clipboard__scheduleReadText(&globalObject, createRequest(WTF::move(completion))); +} + +void scheduleClipboardRead(JSC::JSGlobalObject& globalObject, ClipboardCompletion&& completion) +{ + Bun__Clipboard__scheduleRead(&globalObject, createRequest(WTF::move(completion))); +} + +void scheduleClipboardWriteText(JSC::JSGlobalObject& globalObject, const String& text, ClipboardCompletion&& completion) +{ + auto utf8 = Bun::UTF8View::tryCreate(text); + if (!utf8) [[unlikely]] { + completion(globalObject, {}, "The text is too large to write to the clipboard."_s); + return; + } + auto bytes = utf8->bytes(); + ClipboardRepresentation representation { ClipboardMIMEType::TextPlain, bytes.data(), bytes.size() }; + Bun__Clipboard__scheduleWrite(&globalObject, createRequest(WTF::move(completion)), &representation, 1); +} + +void scheduleClipboardWrite(JSC::JSGlobalObject& globalObject, const ClipboardItemData& data, ClipboardCompletion&& completion) +{ + Vector representations; + representations.reserveInitialCapacity(data.size()); + for (auto& entry : data) { + auto bytes = clipboardBlobBytes(entry.value.get()); + auto type = clipboardMIMETypeFromEssence(ClipboardItem::parseMIMETypeEssence(entry.key)); + RELEASE_ASSERT(type); + representations.append({ *type, bytes.data(), bytes.size() }); + } + Bun__Clipboard__scheduleWrite(&globalObject, createRequest(WTF::move(completion)), representations.span().data(), representations.size()); +} + +} // namespace WebCore + +// JS thread: runs the request's completion once and frees it. +extern "C" void Bun__Clipboard__requestComplete(JSC::JSGlobalObject* globalObject, WebCore::ClipboardRequest* request, const WebCore::ClipboardRepresentation* representations, size_t count, const uint8_t* failureMessage, size_t failureLength) +{ + std::unique_ptr adopted { request }; + WTF::String message; + if (failureMessage) + message = WTF::String::fromUTF8({ failureMessage, failureLength }); + adopted->completion(*globalObject, { representations, count }, message); +} + +// JS thread, the VM is stopping: frees the request without running its completion. +extern "C" void Bun__Clipboard__requestRelease(WebCore::ClipboardRequest* request) +{ + delete request; +} + +// The script's `process.env`, which holds the writes the native env map does not. +// Empty when creating it threw. +extern "C" JSC::EncodedJSValue Bun__Clipboard__processEnv(Zig::GlobalObject* globalObject) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + auto* env = globalObject->processEnvObject(); + RETURN_IF_EXCEPTION(scope, {}); + return JSC::JSValue::encode(env); +} diff --git a/src/jsc/bindings/webcore/ClipboardPlatform.h b/src/jsc/bindings/webcore/ClipboardPlatform.h new file mode 100644 index 000000000000..57af7060f948 --- /dev/null +++ b/src/jsc/bindings/webcore/ClipboardPlatform.h @@ -0,0 +1,47 @@ +#pragma once + +// Boundary to the platform backend (src/runtime/webcore/clipboard.rs): WebCore owns +// every promise and JS value; the backend sees byte ranges and an opaque request. + +#include "root.h" +#include "ClipboardItem.h" +#include +#include +#include + +namespace WebCore { + +// Mirrors `Mime` in clipboard.rs. +enum class ClipboardMIMEType : uint8_t { + TextPlain, + TextHtml, + ImagePng, +}; + +std::optional clipboardMIMETypeFromEssence(StringView); +ASCIILiteral clipboardMIMETypeString(ClipboardMIMEType); + +// The POSIX helper programs own one representation per invocation. +#if OS(DARWIN) || OS(WINDOWS) +constexpr bool clipboardWritesMultipleRepresentations = true; +#else +constexpr bool clipboardWritesMultipleRepresentations = false; +#endif + +// Mirrors `Representation` in clipboard.rs; the bytes are borrowed for the call. +struct ClipboardRepresentation { + ClipboardMIMEType type; + const uint8_t* bytes; + size_t length; +}; + +// Runs once on the JS thread. Empty `representations` is not an error; `failureMessage` is null on success. +using ClipboardCompletion = Function, const String& failureMessage)>; + +void scheduleClipboardReadText(JSC::JSGlobalObject&, ClipboardCompletion&&); +void scheduleClipboardRead(JSC::JSGlobalObject&, ClipboardCompletion&&); +void scheduleClipboardWriteText(JSC::JSGlobalObject&, const String& text, ClipboardCompletion&&); +// Every key must have passed clipboardMIMETypeFromEssence(). +void scheduleClipboardWrite(JSC::JSGlobalObject&, const ClipboardItemData&, ClipboardCompletion&&); + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h index ee30ad6b1a8d..6c8daf5f2fed 100644 --- a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h @@ -93,6 +93,8 @@ class DOMClientIsoSubspaces { GCClient::IsoSubspace* m_clientSubspaceForCookieMap { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForCookieMapIterator { nullptr }; + GCClient::IsoSubspace* m_clientSubspaceForClipboard { nullptr }; + GCClient::IsoSubspace* m_clientSubspaceForClipboardItem { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForFetchHeaders { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForFetchHeadersIterator { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForByteLengthQueuingStrategy { nullptr }; @@ -143,6 +145,7 @@ class DOMClientIsoSubspaces { GCClient::IsoSubspace* m_clientSubspaceForCryptoKey { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForSubtleCrypto { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForBroadcastChannel { nullptr }; + GCClient::IsoSubspace* m_clientSubspaceForClipboardEvent { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForCustomEvent { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForMessageChannel { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForMessageEvent { nullptr }; diff --git a/src/jsc/bindings/webcore/DOMConstructors.h b/src/jsc/bindings/webcore/DOMConstructors.h index b993452e7f10..48632edb759e 100644 --- a/src/jsc/bindings/webcore/DOMConstructors.h +++ b/src/jsc/bindings/webcore/DOMConstructors.h @@ -11,6 +11,9 @@ enum class DOMConstructorID : uint16_t { AbortSignal, BroadcastChannel, ByteLengthQueuingStrategy, + Clipboard, + ClipboardEvent, + ClipboardItem, CloseEvent, CompressionStream, CountQueuingStrategy, diff --git a/src/jsc/bindings/webcore/DOMIsoSubspaces.h b/src/jsc/bindings/webcore/DOMIsoSubspaces.h index 3860de3220bd..8a12ada68102 100644 --- a/src/jsc/bindings/webcore/DOMIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMIsoSubspaces.h @@ -78,6 +78,8 @@ class DOMIsoSubspaces { #include "ZigGeneratedClasses+DOMIsoSubspaces.h" /*-- BUN --*/ + IsoSubspace* m_subspaceForClipboard { nullptr }; + IsoSubspace* m_subspaceForClipboardItem { nullptr }; IsoSubspace* m_subspaceForFetchHeaders { nullptr }; IsoSubspace* m_subspaceForFetchHeadersIterator { nullptr }; IsoSubspace* m_subspaceForByteLengthQueuingStrategy { nullptr }; @@ -129,6 +131,7 @@ class DOMIsoSubspaces { IsoSubspace* m_subspaceForSubtleCrypto { nullptr }; IsoSubspace* m_subspaceForBroadcastChannel { nullptr }; + IsoSubspace* m_subspaceForClipboardEvent { nullptr }; IsoSubspace* m_subspaceForCustomEvent { nullptr }; IsoSubspace* m_subspaceForMessageChannel { nullptr }; diff --git a/src/jsc/bindings/webcore/EventFactory.cpp b/src/jsc/bindings/webcore/EventFactory.cpp index 93fc003acc49..8d7d567dff27 100644 --- a/src/jsc/bindings/webcore/EventFactory.cpp +++ b/src/jsc/bindings/webcore/EventFactory.cpp @@ -39,6 +39,9 @@ JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject* globalObj case EventInterfaceType: { return createWrapper(globalObject, WTF::move(impl)); } + case ClipboardEventInterfaceType: { + return createWrapper(globalObject, WTF::move(impl)); + } case CloseEventInterfaceType: { return createWrapper(globalObject, WTF::move(impl)); } diff --git a/src/jsc/bindings/webcore/EventHeaders.h b/src/jsc/bindings/webcore/EventHeaders.h index faf5d7a900e3..ba964d203282 100644 --- a/src/jsc/bindings/webcore/EventHeaders.h +++ b/src/jsc/bindings/webcore/EventHeaders.h @@ -30,6 +30,8 @@ #include "Event.h" #include "JSEvent.h" +#include "ClipboardEvent.h" +#include "JSClipboardEvent.h" #include "CloseEvent.h" #include "JSCloseEvent.h" #include "ErrorEvent.h" diff --git a/src/jsc/bindings/webcore/EventInterfaces.h b/src/jsc/bindings/webcore/EventInterfaces.h index 2573746d53d5..964788966e72 100644 --- a/src/jsc/bindings/webcore/EventInterfaces.h +++ b/src/jsc/bindings/webcore/EventInterfaces.h @@ -28,6 +28,7 @@ namespace WebCore { enum EventInterface { + ClipboardEventInterfaceType = 53, CloseEventInterfaceType = 54, CustomEventInterfaceType = 56, ErrorEventInterfaceType = 58, diff --git a/src/jsc/bindings/webcore/EventNames.h b/src/jsc/bindings/webcore/EventNames.h index cc4329fd5fee..acb4f9214b0a 100644 --- a/src/jsc/bindings/webcore/EventNames.h +++ b/src/jsc/bindings/webcore/EventNames.h @@ -27,17 +27,19 @@ namespace WebCore { -#define DOM_EVENT_NAMES_FOR_EACH(macro) \ - macro(error) \ - macro(abort) \ - macro(close) \ - macro(open) \ - macro(rename) \ - macro(message) \ - macro(change) \ - macro(messageerror) \ - macro(handshake) \ - macro(resourcetimingbufferfull) +#define DOM_EVENT_NAMES_FOR_EACH(macro) \ + macro(error) \ + macro(abort) \ + macro(close) \ + macro(copy) \ + macro(paste) \ + macro(open) \ + macro(rename) \ + macro(message) \ + macro(change) \ + macro(messageerror) \ + macro(handshake) \ + macro(resourcetimingbufferfull) struct EventNames { WTF_MAKE_NONCOPYABLE(EventNames); diff --git a/src/jsc/bindings/webcore/EventTargetFactory.cpp b/src/jsc/bindings/webcore/EventTargetFactory.cpp index 25155085a200..4f24e05a0c1c 100644 --- a/src/jsc/bindings/webcore/EventTargetFactory.cpp +++ b/src/jsc/bindings/webcore/EventTargetFactory.cpp @@ -45,6 +45,8 @@ JSC::JSValue toJS(JSC::JSGlobalObject* state, JSDOMGlobalObject* globalObject, E return toJS(state, globalObject, static_cast(impl)); case BunWebViewEventTargetInterfaceType: return Bun::toJS(state, globalObject, static_cast(impl)); + case ClipboardEventTargetInterfaceType: + return toJS(state, globalObject, static_cast(impl)); case DOMWindowEventTargetInterfaceType: return globalObject; case MessagePortEventTargetInterfaceType: diff --git a/src/jsc/bindings/webcore/EventTargetHeaders.h b/src/jsc/bindings/webcore/EventTargetHeaders.h index a242b120c531..949f770cbcba 100644 --- a/src/jsc/bindings/webcore/EventTargetHeaders.h +++ b/src/jsc/bindings/webcore/EventTargetHeaders.h @@ -32,7 +32,9 @@ #include "JSAbortSignal.h" #include "BroadcastChannel.h" #include "../../../runtime/webview/JSWebView.h" +#include "Clipboard.h" #include "JSBroadcastChannel.h" +#include "JSClipboard.h" #include "MessagePort.h" #include "JSMessagePort.h" #include "WebSocket.h" diff --git a/src/jsc/bindings/webcore/EventTargetInterfaces.h b/src/jsc/bindings/webcore/EventTargetInterfaces.h index b636f8b86dbb..eb2c7a1ec8a2 100644 --- a/src/jsc/bindings/webcore/EventTargetInterfaces.h +++ b/src/jsc/bindings/webcore/EventTargetInterfaces.h @@ -31,6 +31,7 @@ enum EventTargetInterface { EventTargetInterfaceType = 45, AbortSignalEventTargetInterfaceType = 46, BroadcastChannelEventTargetInterfaceType = 47, + ClipboardEventTargetInterfaceType = 48, DOMWindowEventTargetInterfaceType = 50, MessagePortEventTargetInterfaceType = 61, PerformanceEventTargetInterfaceType = 63, diff --git a/src/jsc/bindings/webcore/JSClipboard.cpp b/src/jsc/bindings/webcore/JSClipboard.cpp new file mode 100644 index 000000000000..110d6c43a4cd --- /dev/null +++ b/src/jsc/bindings/webcore/JSClipboard.cpp @@ -0,0 +1,276 @@ +/* + This file is part of the WebKit open source project. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#include "config.h" +#include "JSClipboard.h" + +#include "ExtendedDOMClientIsoSubspaces.h" +#include "ExtendedDOMIsoSubspaces.h" +#include "JSClipboardItem.h" +#include "JSDOMBinding.h" +#include "JSDOMConstructorNotConstructable.h" +#include "JSDOMConvertInterface.h" +#include "JSDOMConvertSequences.h" +#include "JSDOMConvertStrings.h" +#include "JSDOMExceptionHandling.h" +#include "JSDOMGlobalObjectInlines.h" +#include "JSDOMOperationReturningPromise.h" +#include "JSDOMWrapperCache.h" +#include "ScriptExecutionContext.h" +#include "WebCoreJSClientData.h" +#include "ZigGlobalObject.h" +#include +#include +#include +#include +#include + +namespace WebCore { +using namespace JSC; + +// Attributes and functions + +static JSC_DECLARE_CUSTOM_GETTER(jsClipboardConstructor); +static JSC_DECLARE_HOST_FUNCTION(jsClipboardPrototypeFunction_readText); +static JSC_DECLARE_HOST_FUNCTION(jsClipboardPrototypeFunction_writeText); +static JSC_DECLARE_HOST_FUNCTION(jsClipboardPrototypeFunction_read); +static JSC_DECLARE_HOST_FUNCTION(jsClipboardPrototypeFunction_write); + +class JSClipboardPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static JSClipboardPrototype* create(JSC::VM& vm, JSDOMGlobalObject* globalObject, JSC::Structure* structure) + { + JSClipboardPrototype* ptr = new (NotNull, Bun::allocatePlainObjectCell(vm, sizeof(JSClipboardPrototype))) JSClipboardPrototype(vm, globalObject, structure); + ptr->finishCreation(vm); + return ptr; + } + + DECLARE_INFO; + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSClipboardPrototype, Base); + return &vm.plainObjectSpace(); + } + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return Bun::createClassStructure(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSClipboardPrototype(JSC::VM& vm, JSC::JSGlobalObject*, JSC::Structure* structure) + : JSC::JSNonFinalObject(vm, structure) + { + } + + void finishCreation(JSC::VM&); +}; +STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSClipboardPrototype, JSClipboardPrototype::Base); + +using JSClipboardDOMConstructor = JSDOMConstructorNotConstructable; + +template<> const ClassInfo JSClipboardDOMConstructor::s_info = { "Clipboard"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSClipboardDOMConstructor) }; + +template<> JSValue JSClipboardDOMConstructor::prototypeForStructure(JSC::VM& vm, const JSDOMGlobalObject& globalObject) +{ + return JSEventTarget::getConstructor(vm, &globalObject); +} + +template<> void JSClipboardDOMConstructor::initializeProperties(VM& vm, JSDOMGlobalObject& globalObject) +{ + initializeBaseProperties(vm, 0, "Clipboard"_s, JSClipboard::prototype(vm, globalObject)); +} + +/* Hash table for prototype */ + +static const HashTableValue JSClipboardPrototypeTableValues[] = { + { "constructor"_s, static_cast(JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::GetterSetterType, jsClipboardConstructor, 0 } }, + { "readText"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsClipboardPrototypeFunction_readText, 0 } }, + { "writeText"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsClipboardPrototypeFunction_writeText, 1 } }, + { "read"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsClipboardPrototypeFunction_read, 0 } }, + { "write"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsClipboardPrototypeFunction_write, 1 } }, +}; + +const ClassInfo JSClipboardPrototype::s_info = { "Clipboard"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSClipboardPrototype) }; + +void JSClipboardPrototype::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + Bun::reifyStaticPropertyTable(vm, JSClipboard::info(), JSClipboardPrototypeTableValues, *this); + Bun::putToStringTagWithoutTransition(vm, this, info()); +} + +const ClassInfo JSClipboard::s_info = { "Clipboard"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSClipboard) }; + +JSClipboard::JSClipboard(Structure* structure, JSDOMGlobalObject& globalObject, Ref&& impl) + : JSEventTarget(structure, globalObject, WTF::move(impl)) +{ +} + +void JSClipboard::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); +} + +JSObject* JSClipboard::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + return JSClipboardPrototype::create(vm, &globalObject, JSClipboardPrototype::createStructure(vm, &globalObject, JSEventTarget::prototype(vm, globalObject))); +} + +JSObject* JSClipboard::prototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + return getDOMPrototype(vm, globalObject); +} + +JSValue JSClipboard::getConstructor(VM& vm, const JSGlobalObject* globalObject) +{ + return getDOMConstructor(vm, *uncheckedDowncast(globalObject)); +} + +JSC_DEFINE_CUSTOM_GETTER(jsClipboardConstructor, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* prototype = dynamicDowncast(JSValue::decode(thisValue)); + if (!prototype) [[unlikely]] + return throwVMTypeError(lexicalGlobalObject, throwScope); + return JSValue::encode(JSClipboard::getConstructor(JSC::getVM(lexicalGlobalObject), prototype->globalObject())); +} + +static inline JSC::EncodedJSValue jsClipboardPrototypeFunction_readTextBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperationReturningPromise::ClassParameter castedThis, Ref&& promise) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + UNUSED_PARAM(throwScope); + UNUSED_PARAM(callFrame); + auto& impl = castedThis->wrapped(); + throwScope.release(); + impl.readText(WTF::move(promise)); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsClipboardPrototypeFunction_readText, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperationReturningPromise::call(*lexicalGlobalObject, *callFrame, "readText"_s); +} + +static inline JSC::EncodedJSValue jsClipboardPrototypeFunction_writeTextBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperationReturningPromise::ClassParameter castedThis, Ref&& promise) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + UNUSED_PARAM(throwScope); + auto& impl = castedThis->wrapped(); + if (callFrame->argumentCount() < 1) [[unlikely]] + return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject)); + EnsureStillAliveScope argument0 = callFrame->uncheckedArgument(0); + auto data = convert(*lexicalGlobalObject, argument0.value()); + RETURN_IF_EXCEPTION(throwScope, encodedJSValue()); + throwScope.release(); + impl.writeText(WTF::move(data), WTF::move(promise)); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsClipboardPrototypeFunction_writeText, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperationReturningPromise::call(*lexicalGlobalObject, *callFrame, "writeText"_s); +} + +static inline JSC::EncodedJSValue jsClipboardPrototypeFunction_readBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperationReturningPromise::ClassParameter castedThis, Ref&& promise) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + UNUSED_PARAM(throwScope); + UNUSED_PARAM(callFrame); + auto& impl = castedThis->wrapped(); + throwScope.release(); + impl.read(WTF::move(promise)); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsClipboardPrototypeFunction_read, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperationReturningPromise::call(*lexicalGlobalObject, *callFrame, "read"_s); +} + +static inline JSC::EncodedJSValue jsClipboardPrototypeFunction_writeBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperationReturningPromise::ClassParameter castedThis, Ref&& promise) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + UNUSED_PARAM(throwScope); + auto& impl = castedThis->wrapped(); + if (callFrame->argumentCount() < 1) [[unlikely]] + return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject)); + EnsureStillAliveScope argument0 = callFrame->uncheckedArgument(0); + auto data = convert>>(*lexicalGlobalObject, argument0.value(), [](JSC::JSGlobalObject& lexicalGlobalObject, JSC::ThrowScope& scope) { throwArgumentTypeError(lexicalGlobalObject, scope, 0, "data"_s, "Clipboard"_s, "write"_s, "ClipboardItem"_s); }); + RETURN_IF_EXCEPTION(throwScope, encodedJSValue()); + throwScope.release(); + impl.write(WTF::move(data), WTF::move(promise)); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsClipboardPrototypeFunction_write, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperationReturningPromise::call(*lexicalGlobalObject, *callFrame, "write"_s); +} + +JSC::GCClient::IsoSubspace* JSClipboard::subspaceForImpl(JSC::VM& vm) +{ + return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForClipboard, m_subspaceForClipboard)); +} + +void JSClipboard::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) +{ + auto* thisObject = uncheckedDowncast(cell); + analyzer.setWrappedObjectForCell(cell, &thisObject->wrapped()); + if (thisObject->scriptExecutionContext()) + analyzer.setLabelForCell(cell, makeString("url "_s, thisObject->scriptExecutionContext()->url().string())); + Base::analyzeHeap(cell, analyzer); +} + +bool JSClipboardOwner::isReachableFromOpaqueRoots(JSC::Handle handle, void*, AbstractSlotVisitor& visitor, ASCIILiteral* reason) +{ + auto* jsClipboard = uncheckedDowncast(handle.slot()->asCell()); + ScriptExecutionContext* owner = WTF::getPtr(jsClipboard->wrapped().scriptExecutionContext()); + if (!owner) + return false; + if (reason) [[unlikely]] + *reason = "Reachable from ScriptExecutionContext"_s; + return visitor.containsOpaqueRoot(&jsClipboard->wrapped()); +} + +void JSClipboardOwner::finalize(JSC::Handle handle, void* context) +{ + auto* jsClipboard = static_cast(handle.slot()->asCell()); + auto& world = *static_cast(context); + uncacheWrapper(world, &jsClipboard->wrapped(), jsClipboard); +} + +JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject* globalObject, Ref&& impl) +{ + return createWrapper(globalObject, WTF::move(impl)); +} + +JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, Clipboard& impl) +{ + return wrap(lexicalGlobalObject, globalObject, impl); +} + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/JSClipboard.h b/src/jsc/bindings/webcore/JSClipboard.h new file mode 100644 index 000000000000..4f742d5d307f --- /dev/null +++ b/src/jsc/bindings/webcore/JSClipboard.h @@ -0,0 +1,97 @@ +/* + This file is part of the WebKit open source project. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#pragma once + +#include "Clipboard.h" +#include "JSDOMWrapper.h" +#include "JSEventTarget.h" +#include + +namespace WebCore { + +class JSClipboard : public JSEventTarget { +public: + using Base = JSEventTarget; + using DOMWrapped = Clipboard; + static JSClipboard* create(JSC::Structure* structure, JSDOMGlobalObject* globalObject, Ref&& impl) + { + JSClipboard* ptr = new (NotNull, JSC::allocateCell(globalObject->vm())) JSClipboard(structure, *globalObject, WTF::move(impl)); + ptr->finishCreation(globalObject->vm()); + return ptr; + } + + static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); + static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); + + DECLARE_INFO; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return Bun::createClassStructure(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info(), JSC::NonArray); + } + + static JSC::JSValue getConstructor(JSC::VM&, const JSC::JSGlobalObject*); + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); + Clipboard& wrapped() const + { + return static_cast(Base::wrapped()); + } + +protected: + JSClipboard(JSC::Structure*, JSDOMGlobalObject&, Ref&&); + + void finishCreation(JSC::VM&); +}; + +class JSClipboardOwner final : public JSC::WeakHandleOwner { +public: + bool isReachableFromOpaqueRoots(JSC::Handle, void* context, JSC::AbstractSlotVisitor&, ASCIILiteral*) final; + void finalize(JSC::Handle, void* context) final; +}; + +inline JSC::WeakHandleOwner* wrapperOwner(DOMWrapperWorld&, Clipboard*) +{ + static NeverDestroyed owner; + return &owner.get(); +} + +inline void* wrapperKey(Clipboard* wrappableObject) +{ + return wrappableObject; +} + +JSC::JSValue toJS(JSC::JSGlobalObject*, JSDOMGlobalObject*, Clipboard&); +inline JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, Clipboard* impl) { return impl ? toJS(lexicalGlobalObject, globalObject, *impl) : JSC::jsNull(); } +JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject*, Ref&&); +inline JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, RefPtr&& impl) { return impl ? toJSNewlyCreated(lexicalGlobalObject, globalObject, impl.releaseNonNull()) : JSC::jsNull(); } + +template<> struct JSDOMWrapperConverterTraits { + using WrapperClass = JSClipboard; + using ToWrappedReturnType = Clipboard*; +}; + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/JSClipboardEvent.cpp b/src/jsc/bindings/webcore/JSClipboardEvent.cpp new file mode 100644 index 000000000000..5deaf0e53bc0 --- /dev/null +++ b/src/jsc/bindings/webcore/JSClipboardEvent.cpp @@ -0,0 +1,220 @@ +/* + This file is part of the WebKit open source project. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#include "config.h" +#include "JSClipboardEvent.h" + +#include "ExtendedDOMClientIsoSubspaces.h" +#include "ExtendedDOMIsoSubspaces.h" +#include "JSDOMAttribute.h" +#include "JSDOMBinding.h" +#include "JSDOMConstructor.h" +#include "JSDOMConvertStrings.h" +#include "JSEventInit.h" +#include "JSDOMExceptionHandling.h" +#include "JSDOMGlobalObjectInlines.h" +#include "JSDOMWrapperCache.h" +#include "ScriptExecutionContext.h" +#include "WebCoreJSClientData.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace WebCore { +using namespace JSC; + +// Attributes + +static JSC_DECLARE_CUSTOM_GETTER(jsClipboardEventConstructor); +static JSC_DECLARE_CUSTOM_GETTER(jsClipboardEvent_clipboardData); + +class JSClipboardEventPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static JSClipboardEventPrototype* create(JSC::VM& vm, JSDOMGlobalObject* globalObject, JSC::Structure* structure) + { + JSClipboardEventPrototype* ptr = new (NotNull, Bun::allocatePlainObjectCell(vm, sizeof(JSClipboardEventPrototype))) JSClipboardEventPrototype(vm, globalObject, structure); + ptr->finishCreation(vm); + return ptr; + } + + DECLARE_INFO; + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSClipboardEventPrototype, Base); + return &vm.plainObjectSpace(); + } + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return Bun::createClassStructure(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSClipboardEventPrototype(JSC::VM& vm, JSC::JSGlobalObject*, JSC::Structure* structure) + : JSC::JSNonFinalObject(vm, structure) + { + } + + void finishCreation(JSC::VM&); +}; +STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSClipboardEventPrototype, JSClipboardEventPrototype::Base); + +using JSClipboardEventDOMConstructor = JSDOMConstructor; + +template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSClipboardEventDOMConstructor::construct(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* castedThis = uncheckedDowncast(callFrame->jsCallee()); + ASSERT(castedThis); + if (callFrame->argumentCount() < 1) [[unlikely]] + return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject)); + EnsureStillAliveScope argument0 = callFrame->uncheckedArgument(0); + auto type = convert>(*lexicalGlobalObject, argument0.value()); + RETURN_IF_EXCEPTION(throwScope, {}); + EnsureStillAliveScope argument1 = callFrame->argument(1); + auto eventInitDict = convert>(*lexicalGlobalObject, argument1.value()); + RETURN_IF_EXCEPTION(throwScope, {}); + // `DataTransfer? clipboardData`, read after the inherited members; there is no DataTransfer. + if (auto* init = argument1.value().getObject()) { + auto clipboardData = init->get(lexicalGlobalObject, Identifier::fromString(vm, "clipboardData"_s)); + RETURN_IF_EXCEPTION(throwScope, {}); + if (!clipboardData.isUndefinedOrNull()) [[unlikely]] + return throwVMTypeError(lexicalGlobalObject, throwScope, "ClipboardEventInit.clipboardData must be null: DataTransfer is not supported"_s); + } + auto object = ClipboardEvent::create(WTF::move(type), WTF::move(eventInitDict)); + if constexpr (IsExceptionOr) + RETURN_IF_EXCEPTION(throwScope, {}); + static_assert(TypeOrExceptionOrUnderlyingType::isRef); + auto jsValue = toJSNewlyCreated>(*lexicalGlobalObject, *castedThis->globalObject(), throwScope, WTF::move(object)); + if constexpr (IsExceptionOr) + RETURN_IF_EXCEPTION(throwScope, {}); + setSubclassStructureIfNeeded(lexicalGlobalObject, callFrame, asObject(jsValue)); + RETURN_IF_EXCEPTION(throwScope, {}); + return JSValue::encode(jsValue); +} +JSC_ANNOTATE_HOST_FUNCTION(JSClipboardEventDOMConstructorConstruct, JSClipboardEventDOMConstructor::construct); + +template<> const ClassInfo JSClipboardEventDOMConstructor::s_info = { "ClipboardEvent"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSClipboardEventDOMConstructor) }; + +template<> JSValue JSClipboardEventDOMConstructor::prototypeForStructure(JSC::VM& vm, const JSDOMGlobalObject& globalObject) +{ + return JSEvent::getConstructor(vm, &globalObject); +} + +template<> void JSClipboardEventDOMConstructor::initializeProperties(VM& vm, JSDOMGlobalObject& globalObject) +{ + initializeBaseProperties(vm, 1, "ClipboardEvent"_s, JSClipboardEvent::prototype(vm, globalObject)); +} + +/* Hash table for prototype */ + +static const HashTableValue JSClipboardEventPrototypeTableValues[] = { + { "constructor"_s, static_cast(JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::GetterSetterType, jsClipboardEventConstructor, 0 } }, + { "clipboardData"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute), NoIntrinsic, { HashTableValue::GetterSetterType, jsClipboardEvent_clipboardData, 0 } }, +}; + +const ClassInfo JSClipboardEventPrototype::s_info = { "ClipboardEvent"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSClipboardEventPrototype) }; + +void JSClipboardEventPrototype::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + Bun::reifyStaticPropertyTable(vm, JSClipboardEvent::info(), JSClipboardEventPrototypeTableValues, *this); + Bun::putToStringTagWithoutTransition(vm, this, info()); +} + +const ClassInfo JSClipboardEvent::s_info = { "ClipboardEvent"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSClipboardEvent) }; + +JSClipboardEvent::JSClipboardEvent(Structure* structure, JSDOMGlobalObject& globalObject, Ref&& impl) + : JSEvent(structure, globalObject, WTF::move(impl)) +{ +} + +void JSClipboardEvent::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); +} + +JSObject* JSClipboardEvent::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + return JSClipboardEventPrototype::create(vm, &globalObject, JSClipboardEventPrototype::createStructure(vm, &globalObject, JSEvent::prototype(vm, globalObject))); +} + +JSObject* JSClipboardEvent::prototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + return getDOMPrototype(vm, globalObject); +} + +JSValue JSClipboardEvent::getConstructor(VM& vm, const JSGlobalObject* globalObject) +{ + return getDOMConstructor(vm, *uncheckedDowncast(globalObject)); +} + +JSC_DEFINE_CUSTOM_GETTER(jsClipboardEventConstructor, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* prototype = dynamicDowncast(JSValue::decode(thisValue)); + if (!prototype) [[unlikely]] + return throwVMTypeError(lexicalGlobalObject, throwScope); + return JSValue::encode(JSClipboardEvent::getConstructor(JSC::getVM(lexicalGlobalObject), prototype->globalObject())); +} + +static inline JSValue jsClipboardEvent_clipboardDataGetter(JSGlobalObject&, JSClipboardEvent&) +{ + return JSC::jsNull(); +} + +JSC_DEFINE_CUSTOM_GETTER(jsClipboardEvent_clipboardData, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName attributeName)) +{ + return IDLAttribute::get(*lexicalGlobalObject, thisValue, attributeName); +} + +JSC::GCClient::IsoSubspace* JSClipboardEvent::subspaceForImpl(JSC::VM& vm) +{ + return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForClipboardEvent, m_subspaceForClipboardEvent)); +} + +void JSClipboardEvent::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) +{ + auto* thisObject = uncheckedDowncast(cell); + analyzer.setWrappedObjectForCell(cell, &thisObject->wrapped()); + if (thisObject->scriptExecutionContext()) + analyzer.setLabelForCell(cell, makeString("url "_s, thisObject->scriptExecutionContext()->url().string())); + Base::analyzeHeap(cell, analyzer); +} + +JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject* globalObject, Ref&& impl) +{ + return createWrapper(globalObject, WTF::move(impl)); +} + +JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, ClipboardEvent& impl) +{ + return wrap(lexicalGlobalObject, globalObject, impl); +} + +} diff --git a/src/jsc/bindings/webcore/JSClipboardEvent.h b/src/jsc/bindings/webcore/JSClipboardEvent.h new file mode 100644 index 000000000000..f0e6b1b2bfc4 --- /dev/null +++ b/src/jsc/bindings/webcore/JSClipboardEvent.h @@ -0,0 +1,80 @@ +/* + This file is part of the WebKit open source project. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#pragma once + +#include "ClipboardEvent.h" +#include "JSDOMConvertDictionary.h" +#include "JSDOMWrapper.h" +#include "JSEvent.h" + +namespace WebCore { + +class JSClipboardEvent : public JSEvent { +public: + using Base = JSEvent; + using DOMWrapped = ClipboardEvent; + static JSClipboardEvent* create(JSC::Structure* structure, JSDOMGlobalObject* globalObject, Ref&& impl) + { + JSClipboardEvent* ptr = new (NotNull, JSC::allocateCell(globalObject->vm())) JSClipboardEvent(structure, *globalObject, WTF::move(impl)); + ptr->finishCreation(globalObject->vm()); + return ptr; + } + + static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); + static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); + + DECLARE_INFO; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return Bun::createClassStructure(vm, globalObject, prototype, JSC::TypeInfo(JSC::JSType(JSEventType), StructureFlags), info(), JSC::NonArray); + } + + static JSC::JSValue getConstructor(JSC::VM&, const JSC::JSGlobalObject*); + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); + ClipboardEvent& wrapped() const + { + return static_cast(Base::wrapped()); + } + +protected: + JSClipboardEvent(JSC::Structure*, JSDOMGlobalObject&, Ref&&); + + void finishCreation(JSC::VM&); +}; + +JSC::JSValue toJS(JSC::JSGlobalObject*, JSDOMGlobalObject*, ClipboardEvent&); +inline JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, ClipboardEvent* impl) { return impl ? toJS(lexicalGlobalObject, globalObject, *impl) : JSC::jsNull(); } +JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject*, Ref&&); +inline JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, RefPtr&& impl) { return impl ? toJSNewlyCreated(lexicalGlobalObject, globalObject, impl.releaseNonNull()) : JSC::jsNull(); } + +template<> struct JSDOMWrapperConverterTraits { + using WrapperClass = JSClipboardEvent; + using ToWrappedReturnType = ClipboardEvent*; +}; + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/JSClipboardItem.cpp b/src/jsc/bindings/webcore/JSClipboardItem.cpp new file mode 100644 index 000000000000..d4f6f168da96 --- /dev/null +++ b/src/jsc/bindings/webcore/JSClipboardItem.cpp @@ -0,0 +1,406 @@ +/* + This file is part of the WebKit open source project. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#include "config.h" +#include "JSClipboardItem.h" + +#include "ExtendedDOMClientIsoSubspaces.h" +#include "ExtendedDOMIsoSubspaces.h" +#include "JSDOMAttribute.h" +#include "JSDOMBinding.h" +#include "JSDOMConstructor.h" +#include "JSDOMConvertAny.h" +#include "JSDOMConvertBoolean.h" +#include "JSDOMConvertPromise.h" +#include "JSDOMConvertRecord.h" +#include "JSDOMConvertSequences.h" +#include "JSDOMConvertStrings.h" +#include "JSDOMExceptionHandling.h" +#include "JSDOMGlobalObjectInlines.h" +#include "JSDOMOperationReturningPromise.h" +#include "JSDOMPromise.h" +#include "JSDOMWrapperCache.h" +#include "ScriptExecutionContext.h" +#include "WebCoreJSClientData.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace WebCore { +using namespace JSC; + +static ASCIILiteral presentationStyleString(ClipboardItem::PresentationStyle style) +{ + switch (style) { + case ClipboardItem::PresentationStyle::Unspecified: + return "unspecified"_s; + case ClipboardItem::PresentationStyle::Inline: + return "inline"_s; + case ClipboardItem::PresentationStyle::Attachment: + return "attachment"_s; + } + ASSERT_NOT_REACHED(); + return "unspecified"_s; +} + +// Attributes and functions + +static JSC_DECLARE_CUSTOM_GETTER(jsClipboardItemConstructor); +static JSC_DECLARE_CUSTOM_GETTER(jsClipboardItem_types); +static JSC_DECLARE_CUSTOM_GETTER(jsClipboardItem_presentationStyle); +static JSC_DECLARE_HOST_FUNCTION(jsClipboardItemPrototypeFunction_getType); +static JSC_DECLARE_HOST_FUNCTION(jsClipboardItemConstructorFunction_supports); + +class JSClipboardItemPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static JSClipboardItemPrototype* create(JSC::VM& vm, JSDOMGlobalObject* globalObject, JSC::Structure* structure) + { + JSClipboardItemPrototype* ptr = new (NotNull, Bun::allocatePlainObjectCell(vm, sizeof(JSClipboardItemPrototype))) JSClipboardItemPrototype(vm, globalObject, structure); + ptr->finishCreation(vm); + return ptr; + } + + DECLARE_INFO; + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSClipboardItemPrototype, Base); + return &vm.plainObjectSpace(); + } + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return Bun::createClassStructure(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSClipboardItemPrototype(JSC::VM& vm, JSC::JSGlobalObject*, JSC::Structure* structure) + : JSC::JSNonFinalObject(vm, structure) + { + } + + void finishCreation(JSC::VM&); +}; +STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSClipboardItemPrototype, JSClipboardItemPrototype::Base); + +using JSClipboardItemDOMConstructor = JSDOMConstructor; + +template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSClipboardItemDOMConstructor::construct(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* castedThis = uncheckedDowncast(callFrame->jsCallee()); + ASSERT(castedThis); + + // WebIDL `record` requires an object. The + // converter's own failure message does not name the interface, so the + // common mistake is reported here instead. + JSValue itemsArg = callFrame->argument(0); + if (!itemsArg.isObject()) [[unlikely]] + return JSValue::encode(throwTypeError(lexicalGlobalObject, throwScope, "ClipboardItem requires a record of MIME type to data"_s)); + + // The record conversion is the generator's: it walks own enumerable string + // keys (so Proxy and other exotic objects dispatch correctly) and turns each + // value into a refcounted, GC-guarded DOMPromise via Promise.resolve. + auto record = convert>>(*lexicalGlobalObject, itemsArg); + RETURN_IF_EXCEPTION(throwScope, {}); + + // WebIDL: every argument is converted before any body step runs, so the + // options dictionary (which may invoke a user getter) is read before the + // record's keys are validated. + ClipboardItem::Options options; + JSValue optionsArg = callFrame->argument(1); + if (!optionsArg.isUndefinedOrNull()) { + if (!optionsArg.isObject()) [[unlikely]] + return JSValue::encode(throwTypeError(lexicalGlobalObject, throwScope, "ClipboardItem options must be an object"_s)); + JSValue styleValue = asObject(optionsArg)->get(lexicalGlobalObject, JSC::Identifier::fromString(vm, "presentationStyle"_s)); + RETURN_IF_EXCEPTION(throwScope, {}); + if (!styleValue.isUndefined()) { + String style = styleValue.toWTFString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + if (style == "unspecified"_s) + options.presentationStyle = ClipboardItem::PresentationStyle::Unspecified; + else if (style == "inline"_s) + options.presentationStyle = ClipboardItem::PresentationStyle::Inline; + else if (style == "attachment"_s) + options.presentationStyle = ClipboardItem::PresentationStyle::Attachment; + else [[unlikely]] + return JSValue::encode(throwTypeError(lexicalGlobalObject, throwScope, makeString("\""_s, style, "\" is not a valid value for presentationStyle"_s))); + } + } + + Vector>> items; + items.reserveInitialCapacity(record.size()); + for (auto& entry : record) { + // Spec: `types` holds the serialization of the parsed MIME type + // (mimesniff §4.4), parameters included, as in Chrome. + String normalized = ClipboardItem::parseAndSerializeMIMEType(entry.key); + if (normalized.isEmpty()) [[unlikely]] { + // A spec "web " custom format never parses as a MIME type. Name the + // missing feature instead of calling the key malformed. + if (entry.key.startsWith("web "_s)) + return JSValue::encode(throwTypeError(lexicalGlobalObject, throwScope, makeString("Web custom formats like \""_s, entry.key, "\" are not supported"_s))); + return JSValue::encode(throwTypeError(lexicalGlobalObject, throwScope, makeString("\""_s, entry.key, "\" is not a valid MIME type"_s))); + } + bool duplicate = items.containsIf([&](auto& item) { return item.key == normalized; }); + if (duplicate) [[unlikely]] + return JSValue::encode(throwTypeError(lexicalGlobalObject, throwScope, makeString("Duplicate MIME type \""_s, normalized, "\""_s))); + + if (!entry.value) [[unlikely]] + return JSValue::encode(throwTypeError(lexicalGlobalObject, throwScope, "ClipboardItem representations must be values or promises"_s)); + items.append({ WTF::move(normalized), entry.value.releaseNonNull() }); + } + + auto object = ClipboardItem::create(WTF::move(items), options); + if constexpr (IsExceptionOr) + RETURN_IF_EXCEPTION(throwScope, {}); + auto jsValue = toJSNewlyCreated>(*lexicalGlobalObject, *castedThis->globalObject(), throwScope, WTF::move(object)); + RETURN_IF_EXCEPTION(throwScope, {}); + setSubclassStructureIfNeeded(lexicalGlobalObject, callFrame, asObject(jsValue)); + RETURN_IF_EXCEPTION(throwScope, {}); + return JSValue::encode(jsValue); +} +JSC_ANNOTATE_HOST_FUNCTION(JSClipboardItemDOMConstructorConstruct, JSClipboardItemDOMConstructor::construct); + +template<> const ClassInfo JSClipboardItemDOMConstructor::s_info = { "ClipboardItem"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSClipboardItemDOMConstructor) }; + +template<> JSValue JSClipboardItemDOMConstructor::prototypeForStructure(JSC::VM& vm, const JSDOMGlobalObject& globalObject) +{ + UNUSED_PARAM(vm); + return globalObject.functionPrototype(); +} + +/* Hash table for constructor */ + +static const HashTableValue JSClipboardItemConstructorTableValues[] = { + { "supports"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsClipboardItemConstructorFunction_supports, 1 } }, +}; + +template<> void JSClipboardItemDOMConstructor::initializeProperties(VM& vm, JSDOMGlobalObject& globalObject) +{ + initializeBaseProperties(vm, 1, "ClipboardItem"_s, JSClipboardItem::prototype(vm, globalObject)); + Bun::reifyStaticPropertyTable(vm, JSClipboardItem::info(), JSClipboardItemConstructorTableValues, *this); +} + +/* Hash table for prototype */ + +static const HashTableValue JSClipboardItemPrototypeTableValues[] = { + { "constructor"_s, static_cast(JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::GetterSetterType, jsClipboardItemConstructor, 0 } }, + { "types"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute), NoIntrinsic, { HashTableValue::GetterSetterType, jsClipboardItem_types, 0 } }, + { "presentationStyle"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute), NoIntrinsic, { HashTableValue::GetterSetterType, jsClipboardItem_presentationStyle, 0 } }, + { "getType"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsClipboardItemPrototypeFunction_getType, 1 } }, +}; + +const ClassInfo JSClipboardItemPrototype::s_info = { "ClipboardItem"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSClipboardItemPrototype) }; + +void JSClipboardItemPrototype::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + Bun::reifyStaticPropertyTable(vm, JSClipboardItem::info(), JSClipboardItemPrototypeTableValues, *this); + Bun::putToStringTagWithoutTransition(vm, this, info()); +} + +const ClassInfo JSClipboardItem::s_info = { "ClipboardItem"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSClipboardItem) }; + +JSClipboardItem::JSClipboardItem(Structure* structure, JSDOMGlobalObject& globalObject, Ref&& impl) + : JSDOMWrapper(structure, globalObject, WTF::move(impl)) +{ +} + +void JSClipboardItem::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); + if (auto cost = wrapped().memoryCost()) + vm.heap.reportExtraMemoryAllocated(this, cost); +} + +size_t JSClipboardItem::estimatedSize(JSCell* cell, VM& vm) +{ + auto* thisObject = uncheckedDowncast(cell); + return Base::estimatedSize(cell, vm) + thisObject->wrapped().memoryCost(); +} + +JSObject* JSClipboardItem::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + return JSClipboardItemPrototype::create(vm, &globalObject, JSClipboardItemPrototype::createStructure(vm, &globalObject, globalObject.objectPrototype())); +} + +JSObject* JSClipboardItem::prototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + return getDOMPrototype(vm, globalObject); +} + +JSValue JSClipboardItem::getConstructor(VM& vm, const JSGlobalObject* globalObject) +{ + return getDOMConstructor(vm, *uncheckedDowncast(globalObject)); +} + +JSC_DEFINE_CUSTOM_GETTER(jsClipboardItemConstructor, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* prototype = dynamicDowncast(JSValue::decode(thisValue)); + if (!prototype) [[unlikely]] + return throwVMTypeError(lexicalGlobalObject, throwScope); + return JSValue::encode(JSClipboardItem::getConstructor(JSC::getVM(lexicalGlobalObject), prototype->globalObject())); +} + +static inline JSValue jsClipboardItem_typesGetter(JSGlobalObject& lexicalGlobalObject, JSClipboardItem& thisObject) +{ + auto& vm = JSC::getVM(&lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + + // FrozenArray [SameObject]: hand back the array built on the first get. + if (JSValue cached = thisObject.cachedTypes()) + return cached; + + JSValue types = toJS>(lexicalGlobalObject, *thisObject.globalObject(), throwScope, thisObject.wrapped().types()); + RETURN_IF_EXCEPTION(throwScope, {}); + thisObject.setCachedTypes(vm, types); + return types; +} + +JSC_DEFINE_CUSTOM_GETTER(jsClipboardItem_types, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName attributeName)) +{ + return IDLAttribute::get(*lexicalGlobalObject, thisValue, attributeName); +} + +static inline JSValue jsClipboardItem_presentationStyleGetter(JSGlobalObject& lexicalGlobalObject, JSClipboardItem& thisObject) +{ + auto& vm = JSC::getVM(&lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = thisObject.wrapped(); + RELEASE_AND_RETURN(throwScope, jsNontrivialString(vm, presentationStyleString(impl.presentationStyle()))); +} + +JSC_DEFINE_CUSTOM_GETTER(jsClipboardItem_presentationStyle, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName attributeName)) +{ + return IDLAttribute::get(*lexicalGlobalObject, thisValue, attributeName); +} + +static inline JSC::EncodedJSValue jsClipboardItemPrototypeFunction_getTypeBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperationReturningPromise::ClassParameter castedThis, Ref&& promise) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + UNUSED_PARAM(throwScope); + auto& impl = castedThis->wrapped(); + if (callFrame->argumentCount() < 1) [[unlikely]] + return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject)); + EnsureStillAliveScope argument0 = callFrame->uncheckedArgument(0); + auto type = convert(*lexicalGlobalObject, argument0.value()); + RETURN_IF_EXCEPTION(throwScope, encodedJSValue()); + throwScope.release(); + // Stored keys are serialized MIME types; the data source matches the + // serialization exactly first (spec record equality, parameters included), + // then by essence. An unparsable argument still hits its NotFoundError. + String serialized = ClipboardItem::parseAndSerializeMIMEType(type); + impl.getType(serialized.isEmpty() ? type.convertToASCIILowercase() : serialized, WTF::move(promise)); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsClipboardItemPrototypeFunction_getType, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperationReturningPromise::call(*lexicalGlobalObject, *callFrame, "getType"_s); +} + +JSC_DEFINE_HOST_FUNCTION(jsClipboardItemConstructorFunction_supports, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + if (callFrame->argumentCount() < 1) [[unlikely]] + return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject)); + EnsureStillAliveScope argument0 = callFrame->uncheckedArgument(0); + auto type = convert(*lexicalGlobalObject, argument0.value()); + RETURN_IF_EXCEPTION(throwScope, encodedJSValue()); + RELEASE_AND_RETURN(throwScope, JSValue::encode(jsBoolean(ClipboardItem::supports(type)))); +} + +void JSClipboardItem::destroy(JSC::JSCell* cell) +{ + JSClipboardItem* thisObject = static_cast(cell); + thisObject->JSClipboardItem::~JSClipboardItem(); +} + +JSC::GCClient::IsoSubspace* JSClipboardItem::subspaceForImpl(JSC::VM& vm) +{ + return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForClipboardItem, m_subspaceForClipboardItem)); +} + +template +void JSClipboardItem::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + auto* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_cachedTypes); + visitor.reportExtraMemoryVisited(thisObject->wrapped().memoryCost()); +} + +DEFINE_VISIT_CHILDREN(JSClipboardItem); + +void JSClipboardItem::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) +{ + auto* thisObject = uncheckedDowncast(cell); + analyzer.setWrappedObjectForCell(cell, &thisObject->wrapped()); + if (thisObject->scriptExecutionContext()) + analyzer.setLabelForCell(cell, makeString("url "_s, thisObject->scriptExecutionContext()->url().string())); + Base::analyzeHeap(cell, analyzer); +} + +bool JSClipboardItemOwner::isReachableFromOpaqueRoots(JSC::Handle handle, void*, AbstractSlotVisitor& visitor, ASCIILiteral* reason) +{ + UNUSED_PARAM(handle); + UNUSED_PARAM(visitor); + UNUSED_PARAM(reason); + return false; +} + +void JSClipboardItemOwner::finalize(JSC::Handle handle, void* context) +{ + auto* jsClipboardItem = static_cast(handle.slot()->asCell()); + auto& world = *static_cast(context); + uncacheWrapper(world, &jsClipboardItem->wrapped(), jsClipboardItem); +} + +JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject* globalObject, Ref&& impl) +{ + return createWrapper(globalObject, WTF::move(impl)); +} + +JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, ClipboardItem& impl) +{ + return wrap(lexicalGlobalObject, globalObject, impl); +} + +ClipboardItem* JSClipboardItem::toWrapped(JSC::VM&, JSC::JSValue value) +{ + if (auto* wrapper = dynamicDowncast(value)) + return &wrapper->wrapped(); + return nullptr; +} + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/JSClipboardItem.h b/src/jsc/bindings/webcore/JSClipboardItem.h new file mode 100644 index 000000000000..44e3b0bbb384 --- /dev/null +++ b/src/jsc/bindings/webcore/JSClipboardItem.h @@ -0,0 +1,103 @@ +/* + This file is part of the WebKit open source project. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#pragma once + +#include "ClipboardItem.h" +#include "JSDOMWrapper.h" +#include + +namespace WebCore { + +class JSClipboardItem : public JSDOMWrapper { +public: + using Base = JSDOMWrapper; + static JSClipboardItem* create(JSC::Structure* structure, JSDOMGlobalObject* globalObject, Ref&& impl) + { + JSClipboardItem* ptr = new (NotNull, JSC::allocateCell(globalObject->vm())) JSClipboardItem(structure, *globalObject, WTF::move(impl)); + ptr->finishCreation(globalObject->vm()); + return ptr; + } + + static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); + static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); + static ClipboardItem* toWrapped(JSC::VM&, JSC::JSValue); + static void destroy(JSC::JSCell*); + + DECLARE_INFO; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return Bun::createClassStructure(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info(), JSC::NonArray); + } + + static JSC::JSValue getConstructor(JSC::VM&, const JSC::JSGlobalObject*); + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); + static size_t estimatedSize(JSCell*, JSC::VM&); + + DECLARE_VISIT_CHILDREN; + + // [SameObject] `types`: built on the first get. + JSC::JSValue cachedTypes() const { return m_cachedTypes.get(); } + void setCachedTypes(JSC::VM& vm, JSC::JSValue value) { m_cachedTypes.set(vm, this, value); } + +protected: + JSClipboardItem(JSC::Structure*, JSDOMGlobalObject&, Ref&&); + + void finishCreation(JSC::VM&); + +private: + mutable JSC::WriteBarrier m_cachedTypes; +}; + +class JSClipboardItemOwner final : public JSC::WeakHandleOwner { +public: + bool isReachableFromOpaqueRoots(JSC::Handle, void* context, JSC::AbstractSlotVisitor&, ASCIILiteral*) final; + void finalize(JSC::Handle, void* context) final; +}; + +inline JSC::WeakHandleOwner* wrapperOwner(DOMWrapperWorld&, ClipboardItem*) +{ + static NeverDestroyed owner; + return &owner.get(); +} + +inline void* wrapperKey(ClipboardItem* wrappableObject) +{ + return wrappableObject; +} + +JSC::JSValue toJS(JSC::JSGlobalObject*, JSDOMGlobalObject*, ClipboardItem&); +inline JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, ClipboardItem* impl) { return impl ? toJS(lexicalGlobalObject, globalObject, *impl) : JSC::jsNull(); } +JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject*, Ref&&); +inline JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, RefPtr&& impl) { return impl ? toJSNewlyCreated(lexicalGlobalObject, globalObject, impl.releaseNonNull()) : JSC::jsNull(); } + +template<> struct JSDOMWrapperConverterTraits { + using WrapperClass = JSClipboardItem; + using ToWrappedReturnType = ClipboardItem*; +}; + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/JSDOMPromise.cpp b/src/jsc/bindings/webcore/JSDOMPromise.cpp index f340211f3cb2..ba8f7ddd867b 100644 --- a/src/jsc/bindings/webcore/JSDOMPromise.cpp +++ b/src/jsc/bindings/webcore/JSDOMPromise.cpp @@ -26,11 +26,14 @@ #include "config.h" #include "JSDOMPromise.h" +#include "JSDOMGlobalObject.h" #include #include #include +#include #include #include +#include using namespace JSC; @@ -47,6 +50,14 @@ auto DOMPromise::whenPromiseIsSettled(JSDOMGlobalObject* globalObject, JSC::JSOb return JSC::JSValue::encode(JSC::jsUndefined()); }); + // A native promise takes the reaction directly. `then` looks up the species + // through the promise's `constructor`, which a script can define, so + // calling it could run user code and throw here. + if (auto* nativePromise = dynamicDowncast(promise)) { + nativePromise->performPromiseThen(vm, globalObject, handler, handler, JSC::jsUndefined()); + return IsCallbackRegistered::Yes; + } + auto scope = DECLARE_THROW_SCOPE(vm); const JSC::Identifier& privateName = vm.propertyNames->builtinNames().thenPrivateName(); auto thenFunction = promise->get(&lexicalGlobalObject, privateName); @@ -68,4 +79,50 @@ auto DOMPromise::whenPromiseIsSettled(JSDOMGlobalObject* globalObject, JSC::JSOb return scope.exception() ? IsCallbackRegistered::No : IsCallbackRegistered::Yes; } +// https://github.com/WebKit/WebKit/blob/main/Source/WebCore/bindings/js/JSDOMPromise.cpp +auto DOMPromise::whenSettledWithResult(Function&& callback) -> IsCallbackRegistered +{ + auto* globalObject = this->globalObject(); + if (!globalObject || isSuspended()) + return IsCallbackRegistered::No; + auto& vm = globalObject->vm(); + JSLockHolder lock(vm); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* handler = JSC::JSNativeStdFunction::create(vm, globalObject, 1, String {}, [callback = WTF::move(callback)](JSGlobalObject* globalObject, CallFrame* callFrame) mutable { + if (auto* promise = dynamicDowncast(callFrame->thisValue())) + std::exchange(callback, {})(uncheckedDowncast(globalObject), promise->status() == JSC::JSPromise::Status::Fulfilled, promise->result()); + return JSC::JSValue::encode(JSC::jsUndefined()); + }); + RETURN_IF_EXCEPTION(scope, IsCallbackRegistered::No); + + auto* promise = this->promise(); + auto* thisHandler = JSC::JSBoundFunction::create(vm, globalObject, handler, promise, JSC::ArgList {}, 0, jsEmptyString(vm), JSC::makeSource("createWhenPromiseSettledFunction"_s, JSC::SourceOrigin(), JSC::SourceTaintedOrigin::Untainted)); + RETURN_IF_EXCEPTION(scope, IsCallbackRegistered::No); + if (!thisHandler) [[unlikely]] + return IsCallbackRegistered::No; + + promise->performPromiseThenExported(vm, globalObject, thisHandler, thisHandler, JSC::jsUndefined()); + RETURN_IF_EXCEPTION(scope, IsCallbackRegistered::No); + return IsCallbackRegistered::Yes; +} + +auto DOMPromise::whenFulfilled(JSC::JSPromise& derived, Function&& reaction) -> IsCallbackRegistered +{ + auto* globalObject = this->globalObject(); + if (!globalObject || isSuspended()) + return IsCallbackRegistered::No; + auto& vm = globalObject->vm(); + JSLockHolder lock(vm); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* handler = JSC::JSNativeStdFunction::create(vm, globalObject, 1, String {}, [reaction = WTF::move(reaction)](JSGlobalObject* globalObject, CallFrame* callFrame) mutable { + return JSC::JSValue::encode(std::exchange(reaction, {})(*uncheckedDowncast(globalObject), callFrame->argument(0))); + }); + RETURN_IF_EXCEPTION(scope, IsCallbackRegistered::No); + + // No rejection handler: the rejection reaches `derived` unchanged. + promise()->performPromiseThenExported(vm, globalObject, handler, JSC::jsUndefined(), &derived); + RETURN_IF_EXCEPTION(scope, IsCallbackRegistered::No); + return IsCallbackRegistered::Yes; +} + } diff --git a/src/jsc/bindings/webcore/JSDOMPromise.h b/src/jsc/bindings/webcore/JSDOMPromise.h index 099751e1bd44..6d50746aaf93 100644 --- a/src/jsc/bindings/webcore/JSDOMPromise.h +++ b/src/jsc/bindings/webcore/JSDOMPromise.h @@ -49,6 +49,14 @@ class DOMPromise : public DOMGuarded { static IsCallbackRegistered whenPromiseIsSettled(JSDOMGlobalObject*, JSC::JSObject* promise, Function&&); + IsCallbackRegistered whenSettledWithResult(Function&&); + + // Runs `reaction` with the value once this promise fulfills, the way `then` runs its + // callback: what it returns fulfills `derived`, what it throws rejects `derived`, and a + // rejection of this promise passes through. JSC routes the exceptions, so a reaction that + // runs user code propagates them and never catches. + IsCallbackRegistered whenFulfilled(JSC::JSPromise& derived, Function&& reaction); + private: DOMPromise(JSDOMGlobalObject& globalObject, JSC::JSPromise& promise) : DOMGuarded(globalObject, promise) diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index b31492c3c963..298ac2676260 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -844,8 +844,8 @@ impl Image { // JS-thread synchronous read of the system clipboard for an image // representation, returning a fresh `Bun.Image` wrapping the raw container // bytes. Decode/encode still go through the normal off-thread pipeline; - // only the pasteboard fetch is synchronous, and that's a memcpy of bytes - // the OS already has in-process. `null` ⇔ no image present. Linux returns + // only the pasteboard fetch is synchronous (on macOS it can wait for a + // `navigator.clipboard` job). `null` ⇔ no image present. Linux returns // `null` unconditionally — there's no stable native API to dlopen and // shelling out to `wl-paste`/`xclip` from inside `Bun.Image` is the wrong // layer. diff --git a/src/runtime/image/backend_coregraphics.rs b/src/runtime/image/backend_coregraphics.rs index c8b8811caa8d..f190e3939eee 100644 --- a/src/runtime/image/backend_coregraphics.rs +++ b/src/runtime/image/backend_coregraphics.rs @@ -293,9 +293,9 @@ pub(crate) fn flip(src: &[u8], w: u32, h: u32, horizontal: bool) -> Result i32; diff --git a/src/runtime/image/backend_wic.rs b/src/runtime/image/backend_wic.rs index 6ad5736f1215..7306b967a3c2 100644 --- a/src/runtime/image/backend_wic.rs +++ b/src/runtime/image/backend_wic.rs @@ -844,11 +844,7 @@ unsafe extern "system" { ) -> HRESULT; fn GetHGlobalFromStream(stream: *mut IUnknown, out: *mut *mut c_void) -> HRESULT; } -#[link(name = "kernel32")] -unsafe extern "system" { - fn GlobalLock(h: *mut c_void) -> *mut c_void; - fn GlobalUnlock(h: *mut c_void) -> c_int; -} +use bun_sys::windows::kernel32::{GlobalLock, GlobalUnlock}; /// `WICConvertBitmapSource` is the one flat export from windowscodecs.dll we /// need. Loaded lazily (LoadLibraryA inside `loadFactory`) so the binary @@ -934,27 +930,16 @@ fn load_factory() { // ───────────────────────────── Win32 clipboard ────────────────────────────── // -// JS-thread only — `OpenClipboard` is process-serialised and the static -// `fromClipboard()` accessor calls this synchronously, so no cross-thread -// HGLOBAL hand-off. We prefer the registered "PNG" format (Chrome/Edge/ +// Called synchronously on the JS thread by the static `fromClipboard()` +// accessor: a clipboard busy in this process or another reads as no image. +// We prefer the registered "PNG" format (Chrome/Edge/ // Snipping Tool put it; no transcode loss) and fall back to CF_DIBV5/CF_DIB, // which we re-wrap as a BMP file by prepending the 14-byte BITMAPFILEHEADER // the clipboard omits. Either way the result is bytes the regular Bun.Image // decoder understands; nothing is decoded here. -#[link(name = "user32")] -unsafe extern "system" { - fn OpenClipboard(hwnd: *mut c_void) -> c_int; - fn CloseClipboard() -> c_int; - fn IsClipboardFormatAvailable(format: c_uint) -> c_int; - fn GetClipboardData(format: c_uint) -> *mut c_void; - fn RegisterClipboardFormatA(name: *const core::ffi::c_char) -> c_uint; - fn GetClipboardSequenceNumber() -> u32; -} -#[link(name = "kernel32")] -unsafe extern "system" { - fn GlobalSize(h: *mut c_void) -> usize; -} +use crate::webcore::clipboard::win32::{OpenedClipboard, register_format}; +use bun_sys::windows::user32::{GetClipboardSequenceNumber, IsClipboardFormatAvailable}; const CF_DIB: c_uint = 8; const CF_DIBV5: c_uint = 17; @@ -965,23 +950,17 @@ const CF_DIBV5: c_uint = 17; const NAMED_FORMATS: [&CStr; 4] = [c"PNG", c"image/png", c"JFIF", c"image/webp"]; pub(crate) fn clipboard_change_count() -> i64 { - // SAFETY: GetClipboardSequenceNumber has no preconditions. - unsafe { GetClipboardSequenceNumber() as i64 } + GetClipboardSequenceNumber() as i64 } pub(crate) fn has_clipboard_image() -> bool { // IsClipboardFormatAvailable doesn't require OpenClipboard. - // SAFETY: no preconditions. - if unsafe { IsClipboardFormatAvailable(CF_DIBV5) } != 0 - || unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0 - { + if IsClipboardFormatAvailable(CF_DIBV5) != 0 || IsClipboardFormatAvailable(CF_DIB) != 0 { return true; } for name in NAMED_FORMATS { - // SAFETY: name is a static NUL-terminated C string. - let id = unsafe { RegisterClipboardFormatA(name.as_ptr()) }; - // SAFETY: no preconditions. - if id != 0 && unsafe { IsClipboardFormatAvailable(id) } != 0 { + let id = register_format(name); + if id != 0 && IsClipboardFormatAvailable(id) != 0 { return true; } } @@ -991,42 +970,31 @@ pub(crate) fn has_clipboard_image() -> bool { // The wider `BackendError` type matches the macOS backend so the caller in // Image.rs handles both identically. pub(crate) fn clipboard() -> Result>, BackendError> { - // hwnd=null associates the open with the current task; fine for read-only. - // SAFETY: null hwnd is documented as valid. - if unsafe { OpenClipboard(ptr::null_mut()) } == 0 { - return Err(BackendUnavailable); - } - scopeguard::defer! { - // SAFETY: clipboard is open. - let _ = unsafe { CloseClipboard() }; - } + let mut clipboard = OpenedClipboard::try_open().ok_or(BackendUnavailable)?; // 1. Registered file-format chunks — copy verbatim. for name in NAMED_FORMATS { - // SAFETY: name is a static NUL-terminated C string. - let id = unsafe { RegisterClipboardFormatA(name.as_ptr()) }; + let id = register_format(name); if id != 0 { - // SAFETY: clipboard is open. - let h = unsafe { GetClipboardData(id) }; - if !h.is_null() { - if let Some(b) = dup_global::<0>(h)? { - return Ok(Some(b)); - } + if let Some(b) = dup_global::<0>(&mut clipboard, id)? { + return Ok(Some(b)); } } } - // 2. Packed DIB — needs a synthetic BITMAPFILEHEADER so the BMP sniffer - // and decoder accept it. CF_DIBV5 first (carries alpha mask). The - // clipboard is writable by any local process, so treat the payload as - // hostile: a 1-byte CF_DIB or a header with biSize≈u32::MAX must drop - // the format, not panic the process. + // 2. Packed DIB. + Ok(dib_as_bmp(&mut clipboard)?) +} + +/// The clipboard's packed DIB — needs a synthetic BITMAPFILEHEADER so the BMP +/// sniffer and decoder accept it. CF_DIBV5 first (carries alpha mask). The +/// clipboard is writable by any local process, so treat the payload as +/// hostile: a 1-byte CF_DIB or a header with biSize≈u32::MAX must drop +/// the format, not panic the process. +pub(crate) fn dib_as_bmp( + clipboard: &mut OpenedClipboard, +) -> Result>, bun_alloc::AllocError> { for cf in [CF_DIBV5, CF_DIB] { - // SAFETY: clipboard is open. - let h = unsafe { GetClipboardData(cf) }; - if h.is_null() { - continue; - } - let Some(mut buf) = dup_global::<14>(h)? else { + let Some(mut buf) = dup_global::<14>(clipboard, cf)? else { continue; }; if buf.len() < 14 + 40 || buf.len() as u64 > u32::MAX as u64 { @@ -1034,22 +1002,45 @@ pub(crate) fn clipboard() -> Result>, BackendError> { continue; } // BITMAPFILEHEADER: 'BM' · u32 file-size · 2×u16 reserved · - // u32 bfOffBits. bfOffBits = 14 + biSize + colour-table; for the - // 24/32-bit DIBs clipboards emit there's no colour table, but a - // 40-byte header with BI_BITFIELDS appends 12 bytes of masks. + // u32 bfOffBits. bfOffBits = 14 + biSize + masks + colour-table. The + // table has biClrUsed entries, or every entry of a paletted (<= 8 bit) + // image when that is 0. let ih_size: u64 = u32::from_le_bytes(buf[14..18].try_into().expect("infallible: size matches")) as u64; + let bit_count = u16::from_le_bytes([buf[14 + 14], buf[14 + 15]]); let compression = u32::from_le_bytes( buf[14 + 16..14 + 16 + 4] .try_into() .expect("infallible: size matches"), ); - let masks: u64 = if ih_size == 40 && compression == 3 { + let colors_used = u32::from_le_bytes( + buf[14 + 32..14 + 32 + 4] + .try_into() + .expect("infallible: size matches"), + ) as u64; + // BI_BITFIELDS: three colour masks follow a 40-byte header. A V4/V5 + // header holds its masks itself, but the CF_DIBV5 that Windows + // synthesizes for a bitmap repeats them after the header all the + // same, and a producer's own V5 DIB does not. So look for the repeat. + const BI_BITFIELDS: u32 = 3; + let repeats_header_masks = || { + let after_header = usize::try_from(14 + ih_size).ok()?; + let repeated = buf.get(after_header..after_header.checked_add(12)?)?; + Some(repeated == buf.get(14 + 40..14 + 52)?) + }; + let masks: u64 = if compression == BI_BITFIELDS + && (ih_size == 40 || (ih_size >= 52 && repeats_header_masks() == Some(true))) + { 12 } else { 0 }; - let off = 14 + ih_size + masks; + let table_entries = if colors_used == 0 && (1..=8).contains(&bit_count) { + 1u64 << bit_count + } else { + colors_used + }; + let off = 14 + ih_size + masks + table_entries * 4; if ih_size < 40 || off > buf.len() as u64 { continue; } @@ -1067,25 +1058,14 @@ pub(crate) fn clipboard() -> Result>, BackendError> { /// Copy a clipboard HGLOBAL into the global allocator, optionally leaving /// `PREFIX` zero bytes at the front for the caller to fill (BITMAPFILEHEADER). fn dup_global( - h: *mut c_void, + clipboard: &mut OpenedClipboard, + format: c_uint, ) -> Result>, bun_alloc::AllocError> { - // SAFETY: h is a non-null HGLOBAL from GetClipboardData. - let size = unsafe { GlobalSize(h) }; - if size == 0 { - return Ok(None); - } - // SAFETY: h is a non-null HGLOBAL. - let ptr_ = unsafe { GlobalLock(h) }; - if ptr_.is_null() { - return Ok(None); - } - let ptr_ = ptr_ as *const u8; - scopeguard::defer! { - // SAFETY: h is locked. - let _ = unsafe { GlobalUnlock(h) }; - } - let mut out = vec![0u8; PREFIX + size]; - // SAFETY: ptr_ points to `size` valid bytes inside the locked HGLOBAL. - out[PREFIX..].copy_from_slice(unsafe { bun_core::ffi::slice(ptr_, size) }); - Ok(Some(out)) + Ok(clipboard + .with_data(format, |bytes| { + let mut out = vec![0u8; PREFIX + bytes.len()]; + out[PREFIX..].copy_from_slice(bytes); + out + }) + .filter(|out| out.len() > PREFIX)) } diff --git a/src/runtime/image/codecs.rs b/src/runtime/image/codecs.rs index 0d1ed4a45487..4be0c0d798ed 100644 --- a/src/runtime/image/codecs.rs +++ b/src/runtime/image/codecs.rs @@ -510,6 +510,12 @@ macro_rules! encoded_wrap_free { } impl Encoded { + #[cfg(windows)] + pub(crate) fn as_slice(&self) -> &[u8] { + // SAFETY: `bytes` is a live allocation owned by `self` until `Drop`. + unsafe { self.bytes.as_ref() } + } + #[cfg(any(target_os = "macos", windows))] pub(crate) fn from_owned(bytes: Vec) -> Encoded { let mut bytes = core::mem::ManuallyDrop::new(bytes); diff --git a/src/runtime/webcore.rs b/src/runtime/webcore.rs index b5fe603a2ab3..15c1a0be4713 100644 --- a/src/runtime/webcore.rs +++ b/src/runtime/webcore.rs @@ -13,6 +13,8 @@ pub(crate) mod bake_response; pub(crate) mod byte_blob_loader; #[path = "webcore/ByteStream.rs"] pub(crate) mod byte_stream; +#[path = "webcore/clipboard.rs"] +pub(crate) mod clipboard; #[path = "webcore/CompressionStreamCoder.rs"] pub(crate) mod compression_stream_coder; #[path = "webcore/CookieMap.rs"] diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 540a17195ec0..2e8d9600ea81 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5660,6 +5660,197 @@ pub(crate) extern "C" fn Blob__getSize(value: JSValue) -> usize { unsafe { (*blob).shared_view().len() } } +// Impl-level accessors for `src/jsc/bindings/webcore/ClipboardBlob.h`. + +/// Borrows the Blob's resident bytes; empty when it has none. +/// # Safety +/// `out_ptr` and `out_len` must be valid for writes. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Blob__implGetSpan( + blob: &Blob, + out_ptr: *mut *const u8, + out_len: *mut usize, +) { + let data = blob.shared_view(); + // SAFETY: forwarded from the caller's contract. + unsafe { + *out_ptr = if data.is_empty() { + core::ptr::null() + } else { + data.as_ptr() + }; + *out_len = data.len(); + } +} + +/// Whether reading this Blob would have to touch a file or the network. +#[unsafe(no_mangle)] +pub(crate) extern "C" fn Blob__implNeedsToReadFile(blob: &Blob) -> bool { + blob.needs_to_read_file() || blob.is_s3() +} + +/// Invoked on the JS thread exactly once; `err` is null on success. Neither +/// span outlives the call. +type BlobReadBytesCallback = unsafe extern "C" fn( + ctx: *mut c_void, + ptr: *const u8, + len: usize, + err: *const u8, + err_len: usize, +); + +struct ClipboardBlobReadHandler { + ctx: *mut c_void, + callback: BlobReadBytesCallback, +} + +impl ClipboardBlobReadHandler { + fn finish(self, result: ReadBytesResult) { + match result { + ReadBytesResult::Ok(bytes) => { + // SAFETY: per this type's contract, `callback`/`ctx` are valid + // to invoke once on the JS thread. + unsafe { + (self.callback)(self.ctx, bytes.as_ptr(), bytes.len(), core::ptr::null(), 0) + } + } + ReadBytesResult::Err(e) => { + let code = e.code.to_owned_slice(); + let message = e.message.to_owned_slice(); + let text = match (code.is_empty(), message.is_empty()) { + // fs errors already read "ENOENT: no such file or directory, ...". + (false, false) if message.starts_with(&code) => message, + (false, false) => { + let mut joined = code; + joined.extend_from_slice(b": "); + joined.extend_from_slice(&message); + joined + } + (false, true) => code, + (true, false) => message, + (true, true) => b"The Blob's bytes could not be read.".to_vec(), + }; + // SAFETY: same contract as the success arm. + unsafe { + (self.callback)(self.ctx, core::ptr::null(), 0, text.as_ptr(), text.len()) + } + } + } + } +} + +impl ReadBytesHandler for ClipboardBlobReadHandler { + unsafe fn on_read_bytes(this: *mut Self, result: ReadBytesResult) -> JsResult<()> { + // SAFETY: `this` is the Box leaked in `Blob__implReadBytes`, delivered + // here exactly once per the trait's contract; reclaiming it frees it on + // return. + let boxed = unsafe { bun_core::heap::take(this) }; + // The C++ completion settles its promise itself and leaves nothing pending. + boxed.finish(result); + Ok(()) + } +} + +/// Reads the Blob's bytes (file, S3, or in-memory) and delivers them to +/// `callback` on the JS thread exactly once, synchronously when resident. +/// # Safety +/// `callback` must be callable on the JS thread with `ctx` until it runs. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Blob__implReadBytes( + blob: &Blob, + global: &JSGlobalObject, + ctx: *mut c_void, + callback: BlobReadBytesCallback, +) { + let handler = bun_core::heap::into_raw(Box::new(ClipboardBlobReadHandler { ctx, callback })); + // SAFETY: `handler` is freshly leaked and not used again here; the read + // dispatch hands it to `on_read_bytes` exactly once, also when it returns + // `Err` (a termination hit while delivering synchronously, i.e. after the + // handler has already run the callback), so there is nothing to report. + let _ = unsafe { blob.read_bytes_to_handler(handler, &global.js_thread_of_caller_no_frame()) }; +} + +/// Clears the File-specific fields so a dupe of a File surfaces as a plain +/// Blob; getType() resolves "a new Blob" per +/// https://w3c.github.io/clipboard-apis/#dom-clipboarditem-gettype +#[unsafe(no_mangle)] +pub(crate) extern "C" fn Blob__implClearFile(blob: &mut Blob) { + blob.is_jsdom_file.set(false); + blob.name.set(BunString::DEAD); +} + +/// Sets the Blob's content type verbatim. +/// # Safety +/// `[mime, mime+len)` must be readable. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Blob__implSetContentType( + blob: &mut Blob, + mime: *const u8, + len: usize, +) { + if mime.is_null() || len == 0 { + return; + } + // SAFETY: forwarded from the caller's contract. + let slice = unsafe { bun_core::ffi::slice(mime, len) }; + if !is_valid_blob_type(slice) { + return; + } + blob.content_type.set(BlobContentType::Owned(slice.into())); + blob.content_type_was_set.set(true); +} + +/// Borrows the Blob's content type. The bytes live as long as the Blob. +/// # Safety +/// `out_ptr` and `out_len` must be valid for writes. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Blob__implGetContentType( + blob: &Blob, + out_ptr: *mut *const u8, + out_len: *mut usize, +) { + let slice = blob.content_type_slice(); + // SAFETY: forwarded from the caller's contract. + unsafe { + *out_ptr = slice.as_ptr(); + *out_len = slice.len(); + } +} + +/// `new Blob([bytes], { type: mime })` (C++ `WebCore::Blob::create`). +/// # Safety +/// `[ptr, ptr+len)` and `[mime, mime+mime_len)` must be readable (or null with length 0). +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Blob__fromBytesWithNormalizedType( + global_this: &JSGlobalObject, + ptr: *const u8, + len: usize, + mime: *const u8, + mime_len: usize, +) -> *mut Blob { + // SAFETY: forwarded from the caller's contract. + let blob = unsafe { Blob__fromBytes(global_this, ptr, len) }; + if mime.is_null() || mime_len == 0 { + return blob; + } + // SAFETY: forwarded from the caller's contract. + let slice = unsafe { bun_core::ffi::slice(mime, mime_len) }; + if !is_valid_blob_type(slice) { + return blob; + } + // SAFETY: `blob` is a fresh heap allocation we solely own until returned. + unsafe { + (*blob) + .content_type + .set(match global_this.bun_vm().as_mut().mime_type(slice) { + Some(mime) => BlobContentType::from(mime), + None => BlobContentType::from_lowercased(slice), + }); + (*blob).content_type_was_set.set(true); + } + blob +} + /// # Safety /// `[ptr, ptr+len)` must be a valid readable byte range (or `ptr` null / `len` 0). #[unsafe(no_mangle)] diff --git a/src/runtime/webcore/clipboard.rs b/src/runtime/webcore/clipboard.rs new file mode 100644 index 000000000000..8b124a228320 --- /dev/null +++ b/src/runtime/webcore/clipboard.rs @@ -0,0 +1,1255 @@ +//! `navigator.clipboard` platform I/O: https://w3c.github.io/clipboard-apis/ + +use core::ffi::c_void; +use core::mem::ManuallyDrop; +use core::ptr; +use core::sync::atomic::{AtomicU64, Ordering}; +use std::borrow::Cow; + +use bun_jsc::job::JsAffine; +use bun_jsc::{ + CallFrame, Completion, JSGlobalObject, JSValue, Job, JobContext, JsError, JsResult, JsThread, + Ticket, +}; + +/// `WebCore::ClipboardRequest`, completed or released on the JS thread. +struct Request(*mut c_void); + +// SAFETY: used and dropped only on the JS thread, which the job carrier +// guarantees for its `Js` side. +unsafe impl JsAffine for Request {} + +impl Request { + fn complete(self, global: &JSGlobalObject, outcome: &Outcome) { + let request = ManuallyDrop::new(self).0; + let (representations, failure) = match outcome { + Ok(items) => ( + items + .iter() + .map(|(mime, bytes)| Representation { + mime: *mime, + bytes: bytes.as_ptr(), + len: bytes.len(), + }) + .collect::>(), + None, + ), + Err(unavailable) => (Vec::new(), Some(unavailable.message())), + }; + let (message, message_len) = failure.as_deref().map_or((ptr::null(), 0), |message| { + (message.as_ptr(), message.len()) + }); + // SAFETY: JS thread with a live global; the views borrow `outcome` and + // `failure` for the call, which consumes the request. + unsafe { + Bun__Clipboard__requestComplete( + global, + request, + representations.as_ptr(), + representations.len(), + message, + message_len, + ) + }; + } +} + +impl Drop for Request { + fn drop(&mut self) { + // SAFETY: JS thread; `complete` bypasses Drop, so the request is live. + unsafe { Bun__Clipboard__requestRelease(self.0) }; + } +} + +/// Mirrors `WebCore::ClipboardRepresentation`; the bytes are borrowed for the call. +#[repr(C)] +pub(crate) struct Representation { + mime: Mime, + bytes: *const u8, + len: usize, +} + +unsafe extern "C" { + /// A null `failure_message` means the operation succeeded. + fn Bun__Clipboard__requestComplete( + global: &JSGlobalObject, + request: *mut c_void, + representations: *const Representation, + count: usize, + failure_message: *const u8, + failure_length: usize, + ); + fn Bun__Clipboard__requestRelease(request: *mut c_void); +} + +/// Mirrors `WebCore::ClipboardMIMEType`. +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mime { + TextPlain, + TextHtml, + ImagePng, +} + +impl Mime { + const ALL: [Mime; 3] = [Mime::TextPlain, Mime::TextHtml, Mime::ImagePng]; +} + +type Outcome = Result)>, Unavailable>; + +enum Op { + ReadText, + Read, + Write(Vec<(Mime, Vec)>), +} + +struct ClipboardOp { + op: Op, + env: platform::Env, + outcome: Outcome, +} + +struct ClipboardJob; + +impl JobContext for ClipboardJob { + type OffThread = ClipboardOp; + type Js = Request; + + fn run(this: &mut ClipboardOp, done: Completion) -> Option> { + let ticket = done.ticket(); + this.outcome = match &this.op { + Op::ReadText => platform::read_types(&[Mime::TextPlain], &this.env, ticket), + Op::Read => platform::read_types(&Mime::ALL, &this.env, ticket), + Op::Write(items) => { + platform::write_types(items, &this.env, ticket).map(|()| Vec::new()) + } + }; + Some(done) + } + + fn then(this: ClipboardOp, request: Request, cx: &JsThread<'_>) -> bun_jsc::JsResult<()> { + request.complete(cx.global(), &this.outcome); + Ok(()) + } +} + +/// Whether the operation was scheduled. When it was not, the request is released +/// and the exception is left pending, which the promise operation turns into the +/// rejection. +fn schedule(global: &JSGlobalObject, op: Op, request: *mut c_void) -> bool { + let request = Request(request); + let env = match platform::Env::snapshot(global) { + Ok(env) => env, + Err(JsError::Thrown | JsError::Terminated) => return false, + Err(JsError::OutOfMemory) => { + let _ = global.throw_out_of_memory(); + return false; + } + }; + let off = ClipboardOp { + op, + env, + outcome: Err(Unavailable::Platform), + }; + Job::::schedule(&global.js_thread_of_caller_no_frame(), off, request); + true +} + +#[unsafe(no_mangle)] +pub(crate) extern "C" fn Bun__Clipboard__scheduleReadText( + global: &JSGlobalObject, + request: *mut c_void, +) -> bool { + schedule(global, Op::ReadText, request) +} + +#[unsafe(no_mangle)] +pub(crate) extern "C" fn Bun__Clipboard__scheduleRead( + global: &JSGlobalObject, + request: *mut c_void, +) -> bool { + schedule(global, Op::Read, request) +} + +/// # Safety +/// `representations[..count]` and each entry's bytes must be readable for this call. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__Clipboard__scheduleWrite( + global: &JSGlobalObject, + request: *mut c_void, + representations: *const Representation, + count: usize, +) -> bool { + // SAFETY: forwarded from the caller's contract. + let entries = unsafe { bun_core::ffi::slice(representations, count) }; + let items = entries + .iter() + .map(|entry| { + // SAFETY: forwarded from the caller's contract. + let bytes = unsafe { bun_core::ffi::slice(entry.bytes, entry.len) }; + (entry.mime, bytes.to_vec()) + }) + .collect(); + schedule(global, Op::Write(items), request) +} + +/// How long one run of a helper program may take, in milliseconds. Only the +/// Linux/BSD backend runs helpers. +static HELPER_TIMEOUT_MS: AtomicU64 = AtomicU64::new(10_000); + +/// `bun:internal-for-testing`'s `setClipboardHelperTimeoutForTesting(milliseconds)`. +/// Returns the limit it replaces. +#[bun_jsc::host_fn] +pub(crate) fn set_helper_timeout_for_testing( + global: &JSGlobalObject, + callframe: &CallFrame, +) -> JsResult { + let [limit] = callframe.arguments_as_array::<1>(); + if !limit.is_number() { + return Err(global.throw_invalid_arguments(format_args!( + "setClipboardHelperTimeoutForTesting expects a number" + ))); + } + let milliseconds = + u64::try_from(limit.coerce_to_int64(global)?.max(1)).expect("a positive i64 fits u64"); + let previous = HELPER_TIMEOUT_MS.swap(milliseconds, Ordering::Relaxed); + Ok(JSValue::js_number(previous as f64)) +} + +/// Why the platform clipboard could not be used; the `NotAllowedError` message. +enum Unavailable { + Platform, + #[cfg(target_os = "macos")] + Changing, + #[cfg(not(any(target_os = "macos", windows)))] + NoDisplay, + #[cfg(not(any(target_os = "macos", windows)))] + NoHelper, + #[cfg(not(any(target_os = "macos", windows)))] + HelperFailed, + #[cfg(not(any(target_os = "macos", windows)))] + Spawn(bun_sys::Error), +} + +impl Unavailable { + fn message(&self) -> Cow<'static, [u8]> { + Cow::Borrowed(match self { + Unavailable::Platform => b"The system clipboard is not available.".as_slice(), + #[cfg(target_os = "macos")] + Unavailable::Changing => b"The system clipboard changed while it was being read.", + #[cfg(not(any(target_os = "macos", windows)))] + Unavailable::NoDisplay => { + b"The clipboard requires a Wayland or X11 display, but neither $WAYLAND_DISPLAY nor $DISPLAY is set." + } + #[cfg(not(any(target_os = "macos", windows)))] + Unavailable::NoHelper => { + b"No clipboard helper was found. Install `wl-clipboard` (Wayland) or `xclip` (X11)." + } + #[cfg(not(any(target_os = "macos", windows)))] + Unavailable::HelperFailed => b"The clipboard helper program failed to access the clipboard.", + #[cfg(not(any(target_os = "macos", windows)))] + Unavailable::Spawn(error) => { + return Cow::Owned(format!("The clipboard helper could not be started: {error}").into_bytes()); + } + }) + } +} + +// ─── macOS: NSPasteboard via `image_coregraphics_shim.cpp` ────────────────── +#[cfg(target_os = "macos")] +mod platform { + use core::ffi::{CStr, c_char, c_void}; + + use super::{JSGlobalObject, Mime, Outcome, Ticket, Unavailable}; + + /// The pasteboard does not depend on the environment. + pub(super) struct Env; + + impl Env { + pub(super) fn snapshot(_global: &JSGlobalObject) -> bun_jsc::JsResult { + Ok(Env) + } + } + + const CG_OK: i32 = 0; + const CG_CLIPBOARD_CHANGED: i32 = 5; + + unsafe extern "C" { + fn bun_coregraphics_clipboard_read_types( + utis: *const *const c_char, + count: usize, + out_datas: *mut *mut c_void, + out_lens: *mut usize, + ) -> i32; + fn bun_coregraphics_clipboard_take_data(data: *mut c_void, out: *mut u8) -> i32; + fn bun_coregraphics_clipboard_write_types( + utis: *const *const c_char, + datas: *const *const u8, + lens: *const usize, + count: usize, + ) -> i32; + } + + fn uti(mime: Mime) -> &'static CStr { + match mime { + Mime::TextPlain => c"public.utf8-plain-text", + Mime::TextHtml => c"public.html", + Mime::ImagePng => c"public.png", + } + } + + pub(super) fn read_types(types: &[Mime], _env: &Env, _ticket: &Ticket) -> Outcome { + let utis: Vec<*const c_char> = types.iter().map(|&mime| uti(mime).as_ptr()).collect(); + let mut datas: Vec<*mut c_void> = vec![core::ptr::null_mut(); types.len()]; + let mut lens = vec![0usize; types.len()]; + // Another process writing between the per-type reads would tear the item. + for _ in 0..4 { + // SAFETY: the three arrays are `types.len()` long and the UTIs are static. + let status = unsafe { + bun_coregraphics_clipboard_read_types( + utis.as_ptr(), + types.len(), + datas.as_mut_ptr(), + lens.as_mut_ptr(), + ) + }; + if status == CG_CLIPBOARD_CHANGED { + continue; + } + if status != CG_OK { + return Err(Unavailable::Platform); + } + let mut present = Vec::new(); + let mut copied = true; + for ((&mime, &data), &len) in types.iter().zip(&datas).zip(&lens) { + if data.is_null() { + continue; + } + let mut bytes = vec![0u8; len]; + // SAFETY: `data` is the retained `len`-byte NSData the call above + // handed over; this copies it into `bytes` and releases it. + copied &= unsafe { bun_coregraphics_clipboard_take_data(data, bytes.as_mut_ptr()) } + == CG_OK; + present.push((mime, bytes)); + } + return if copied { + Ok(present) + } else { + Err(Unavailable::Platform) + }; + } + Err(Unavailable::Changing) + } + + pub(super) fn write_types( + items: &[(Mime, Vec)], + _env: &Env, + _ticket: &Ticket, + ) -> Result<(), Unavailable> { + if items.is_empty() { + return Ok(()); + } + let utis: Vec<*const c_char> = items.iter().map(|(mime, _)| uti(*mime).as_ptr()).collect(); + let datas: Vec<*const u8> = items.iter().map(|(_, bytes)| bytes.as_ptr()).collect(); + let lens: Vec = items.iter().map(|(_, bytes)| bytes.len()).collect(); + // SAFETY: the three arrays are index-aligned and outlive the call, which + // copies every payload. + let status = unsafe { + bun_coregraphics_clipboard_write_types( + utis.as_ptr(), + datas.as_ptr(), + lens.as_ptr(), + items.len(), + ) + }; + if status == CG_OK { + Ok(()) + } else { + Err(Unavailable::Platform) + } + } +} + +// ─── Windows ──────────────────────────────────────────────────────────────── +#[cfg(windows)] +pub(crate) mod win32 { + use core::ffi::{CStr, c_uint}; + use core::marker::PhantomData; + use core::mem::ManuallyDrop; + + use bun_sys::windows::{HANDLE, kernel32, user32}; + + /// `OpenClipboard(NULL)` does not exclude this process's other threads. + static TRANSACTION: bun_threading::Mutex = bun_threading::Mutex::new(); + + /// The clipboard, open on this thread until dropped. + pub(crate) struct OpenedClipboard { + _not_send: PhantomData<*const ()>, + } + + impl OpenedClipboard { + /// Waits for this process and retries briefly while another one holds the clipboard. + pub(crate) fn open() -> Option { + const ATTEMPTS: u32 = 5; + TRANSACTION.lock(); + for attempt in 1..=ATTEMPTS { + if let Some(clipboard) = Self::open_locked() { + return Some(clipboard); + } + if attempt < ATTEMPTS { + kernel32::Sleep(5 * attempt); + } + } + TRANSACTION.unlock(); + None + } + + /// One attempt without waiting, for callers on the JS thread. + pub(crate) fn try_open() -> Option { + if !TRANSACTION.try_lock() { + return None; + } + let clipboard = Self::open_locked(); + if clipboard.is_none() { + TRANSACTION.unlock(); + } + clipboard + } + + fn open_locked() -> Option { + (user32::OpenClipboard(core::ptr::null_mut()) != 0).then_some(OpenedClipboard { + _not_send: PhantomData, + }) + } + + /// `f` sees `format`'s bytes, `GlobalSize` long; `None` when absent or unlockable. + pub(crate) fn with_data( + &mut self, + format: c_uint, + f: impl FnOnce(&[u8]) -> R, + ) -> Option { + let h = user32::GetClipboardData(format); + if h.is_null() { + return None; + } + // SAFETY: `h` belongs to the open clipboard, which `&mut self` keeps + // unchanged for this call. + let p = unsafe { kernel32::GlobalLock(h) }; + if p.is_null() { + return None; + } + // SAFETY: locked, so `GlobalSize` bytes stay readable until the unlock. + let result = + f(unsafe { core::slice::from_raw_parts(p.cast::(), kernel32::GlobalSize(h)) }); + // SAFETY: balances the lock above. + unsafe { kernel32::GlobalUnlock(h) }; + Some(result) + } + + /// Replaces the contents; a failure part-way leaves the clipboard empty. + pub(crate) fn replace(&mut self, formats: Vec<(c_uint, OwnedGlobal)>) -> bool { + // SAFETY: open on this thread, and `&mut self` rules out a live `with_data` view. + if unsafe { user32::EmptyClipboard() } == 0 { + return false; + } + for (format, global) in formats { + let global = ManuallyDrop::new(global); + // SAFETY: as above; `global` is an unlocked HGLOBAL this process owns. + if unsafe { user32::SetClipboardData(format, global.0) }.is_null() { + drop(ManuallyDrop::into_inner(global)); + // SAFETY: as above. + unsafe { user32::EmptyClipboard() }; + return false; + } + } + true + } + } + + impl Drop for OpenedClipboard { + fn drop(&mut self) { + // SAFETY: open on this thread; no `with_data` view outlives `self`. + unsafe { user32::CloseClipboard() }; + TRANSACTION.unlock(); + } + } + + /// 0 on failure; a name always maps to the same id. + pub(crate) fn register_format(name: &CStr) -> c_uint { + // SAFETY: `&CStr` is a readable NUL-terminated name. + unsafe { user32::RegisterClipboardFormatA(name.as_ptr()) } + } + + /// A movable HGLOBAL this process owns, freed on drop unless the clipboard takes it. + pub(crate) struct OwnedGlobal(HANDLE); + + impl OwnedGlobal { + /// Allocates at least one byte: a 0-byte HGLOBAL cannot be locked. + pub(crate) fn from_bytes(bytes: &[u8]) -> Option { + let h = kernel32::GlobalAlloc( + kernel32::GMEM_MOVEABLE | kernel32::GMEM_ZEROINIT, + bytes.len().max(1), + ); + if h.is_null() { + return None; + } + let global = OwnedGlobal(h); + // SAFETY: a fresh unlocked allocation of at least `bytes.len()` bytes. + unsafe { + let dst = kernel32::GlobalLock(h); + if dst.is_null() { + return None; + } + core::ptr::copy_nonoverlapping(bytes.as_ptr(), dst.cast::(), bytes.len()); + kernel32::GlobalUnlock(h); + } + Some(global) + } + } + + impl Drop for OwnedGlobal { + fn drop(&mut self) { + // SAFETY: still ours: `replace` forgets the handles the clipboard takes. + unsafe { kernel32::GlobalFree(self.0) }; + } + } +} + +#[cfg(windows)] +mod platform { + use core::ffi::{CStr, c_uint}; + + use bun_sys::windows::user32::CF_UNICODETEXT; + + use super::win32::{OpenedClipboard, OwnedGlobal, register_format}; + use super::{JSGlobalObject, Mime, Outcome, Ticket, Unavailable}; + + /// The clipboard does not depend on the environment. + pub(super) struct Env; + + impl Env { + pub(super) fn snapshot(_global: &JSGlobalObject) -> bun_jsc::JsResult { + Ok(Env) + } + } + + const CF_DIBV5: c_uint = 17; + + fn register(name: &CStr) -> Option { + match register_format(name) { + 0 => None, + id => Some(id), + } + } + + /// Producers register PNG under either name. + fn read_formats(mime: Mime) -> [Option; 2] { + match mime { + Mime::TextPlain => [Some(CF_UNICODETEXT), None], + Mime::ImagePng => [register(c"PNG"), register(c"image/png")], + Mime::TextHtml => [register(c"HTML Format"), None], + } + } + + fn write_format(mime: Mime) -> Option { + match mime { + Mime::TextPlain => Some(CF_UNICODETEXT), + Mime::ImagePng => register(c"PNG"), + Mime::TextHtml => register(c"HTML Format"), + } + } + + /// https://learn.microsoft.com/en-us/windows/win32/dataxchg/html-clipboard-format + fn build_cf_html(fragment: &[u8]) -> Vec { + const PREFIX: &str = "\r\n\r\n"; + const SUFFIX: &str = "\r\n\r\n"; + const HEADER_LEN: usize = "Version:0.9\r\nStartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:0000000000\r\nEndFragment:0000000000\r\n".len(); + let start_html = HEADER_LEN; + let start_fragment = start_html + PREFIX.len(); + let end_fragment = start_fragment + fragment.len(); + let end_html = end_fragment + SUFFIX.len(); + let mut out = format!( + "Version:0.9\r\nStartHTML:{start_html:010}\r\nEndHTML:{end_html:010}\r\nStartFragment:{start_fragment:010}\r\nEndFragment:{end_fragment:010}\r\n{PREFIX}" + ) + .into_bytes(); + out.extend_from_slice(fragment); + out.extend_from_slice(SUFFIX.as_bytes()); + out.push(0); + out + } + + fn cf_html_offset(payload: &[u8], key: &[u8]) -> Option { + let at = bun_core::strings::index_of(payload, key)?; + let digits = &payload[at + key.len()..]; + let end = digits.iter().position(|byte| !byte.is_ascii_digit())?; + core::str::from_utf8(&digits[..end]).ok()?.parse().ok() + } + + /// Checks another program's offsets, falling back to the fragment markers. + fn cf_html_fragment(payload: &[u8]) -> Option> { + if let (Some(start), Some(end)) = ( + cf_html_offset(payload, b"StartFragment:"), + cf_html_offset(payload, b"EndFragment:"), + ) && start <= end + && end <= payload.len() + { + return Some(payload[start..end].to_vec()); + } + const START_MARK: &[u8] = b""; + const END_MARK: &[u8] = b""; + let start = bun_core::strings::index_of(payload, START_MARK)? + START_MARK.len(); + let end = start + bun_core::strings::index_of(&payload[start..], END_MARK)?; + Some(payload[start..end].to_vec()) + } + + /// Stops at the first NUL without trusting one to exist. + fn text_from_utf16(bytes: &[u8]) -> Vec { + let units: Vec = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .take_while(|&unit| unit != 0) + .collect(); + String::from_utf16_lossy(&units).into_bytes() + } + + /// Cuts `GlobalSize` slack after https://www.w3.org/TR/png-3/#11IEND + fn trim_png(bytes: &[u8]) -> &[u8] { + const IEND: &[u8] = b"IEND\xAE\x42\x60\x82"; + match bun_core::strings::last_index_of(bytes, IEND) { + Some(at) => &bytes[..at + IEND.len()], + None => bytes, + } + } + + fn png_from_bmp(bmp: &[u8]) -> Option> { + use crate::image::codecs::{self, DecodeHint, EncodeOptions, Format}; + let image = codecs::decode(bmp, codecs::DEFAULT_MAX_PIXELS, DecodeHint::default()).ok()?; + let options = EncodeOptions { + format: Format::Png, + ..Default::default() + }; + let png = codecs::encode(&image.rgba, image.width, image.height, options).ok()?; + Some(png.as_slice().to_vec()) + } + + enum Read { + Bytes(Vec), + /// A bitmap-only clipboard (a screenshot, Paint) still offers PNG, as a + /// TIFF-only pasteboard does on macOS; converted once the clipboard is closed. + Bitmap(Vec), + } + + fn read_type(clipboard: &mut OpenedClipboard, mime: Mime) -> Option { + for format in read_formats(mime).into_iter().flatten() { + // Memory another app left unlockable reads as absent. + if let Some(bytes) = clipboard.with_data(format, |bytes| match mime { + Mime::TextPlain => Some(text_from_utf16(bytes)), + Mime::ImagePng => Some(trim_png(bytes).to_vec()), + Mime::TextHtml => { + let end = + bun_core::strings::index_of_char_usize(bytes, 0).unwrap_or(bytes.len()); + cf_html_fragment(&bytes[..end]) + } + }) { + return bytes.map(Read::Bytes); + } + } + if mime == Mime::ImagePng { + return crate::image::backend_wic::dib_as_bmp(clipboard) + .ok()? + .map(Read::Bitmap); + } + None + } + + /// One open span, so no other process writes between the types. + pub(super) fn read_types(types: &[Mime], _env: &Env, _ticket: &Ticket) -> Outcome { + let read: Vec<(Mime, Read)> = { + let mut clipboard = OpenedClipboard::open().ok_or(Unavailable::Platform)?; + types + .iter() + .filter_map(|&mime| Some((mime, read_type(&mut clipboard, mime)?))) + .collect() + }; + Ok(read + .into_iter() + .filter_map(|(mime, read)| match read { + Read::Bytes(bytes) => Some((mime, bytes)), + Read::Bitmap(bmp) => Some((mime, png_from_bmp(&bmp)?)), + }) + .collect()) + } + + /// Bare `\n` becomes `\r\n`: https://w3c.github.io/clipboard-apis/#dom-clipboard-writetext + fn normalize_to_crlf(bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(bytes.len() + 16); + let mut prev = 0u8; + for &byte in bytes { + if byte == b'\n' && prev != b'\r' { + out.push(b'\r'); + } + out.push(byte); + prev = byte; + } + out + } + + fn make_global(mime: Mime, bytes: &[u8]) -> Option { + match mime { + Mime::TextPlain => { + let crlf; + let text = if bun_core::strings::contains_char(bytes, b'\n') { + crlf = normalize_to_crlf(bytes); + &crlf[..] + } else { + bytes + }; + // CF_UNICODETEXT is NUL-terminated UTF-16. + let wide = bun_core::strings::to_utf16_alloc_for_real(text, false, true).ok()?; + OwnedGlobal::from_bytes(bytemuck::cast_slice::(&wide)) + } + Mime::TextHtml => OwnedGlobal::from_bytes(&build_cf_html(bytes)), + Mime::ImagePng => OwnedGlobal::from_bytes(bytes), + } + } + + /// https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header + fn dibv5_from_png(png: &[u8]) -> Option> { + const HEADER_SIZE: u32 = 124; + const BI_BITFIELDS: u32 = 3; + const LCS_SRGB: u32 = u32::from_be_bytes(*b"sRGB"); + const LCS_GM_IMAGES: u32 = 4; + let image = + crate::image::backend_wic::decode(png, crate::image::codecs::DEFAULT_MAX_PIXELS) + .ok()?; + if image.width == 0 || image.height == 0 { + return None; + } + let size_image = u32::try_from(image.rgba.len()).ok()?; + let mut dib = Vec::with_capacity(HEADER_SIZE as usize + image.rgba.len()); + let mut put = |value: u32| dib.extend_from_slice(&value.to_le_bytes()); + // Positive height: rows run bottom-up. + for value in [HEADER_SIZE, image.width, image.height, 1 | (32 << 16)] { + put(value); + } + for value in [BI_BITFIELDS, size_image, 0, 0, 0, 0] { + put(value); + } + for value in [0x00FF_0000, 0x0000_FF00, 0x0000_00FF, 0xFF00_0000, LCS_SRGB] { + put(value); + } + dib.resize(dib.len() + 36 + 12, 0); + for value in [LCS_GM_IMAGES, 0, 0, 0] { + dib.extend_from_slice(&u32::to_le_bytes(value)); + } + debug_assert_eq!(dib.len(), HEADER_SIZE as usize); + for row in image.rgba.chunks_exact(image.width as usize * 4).rev() { + for pixel in row.as_chunks::<4>().0 { + dib.extend_from_slice(&[pixel[2], pixel[1], pixel[0], pixel[3]]); + } + } + Some(dib) + } + + pub(super) fn write_types( + items: &[(Mime, Vec)], + _env: &Env, + _ticket: &Ticket, + ) -> Result<(), Unavailable> { + // Everything fallible happens before the clipboard is emptied. + let mut formats = Vec::with_capacity(items.len() + 1); + for (mime, bytes) in items { + let format = write_format(*mime).ok_or(Unavailable::Platform)?; + formats.push(( + format, + make_global(*mime, bytes).ok_or(Unavailable::Platform)?, + )); + // The bitmap is an extra; without it the PNG is still written. + if *mime == Mime::ImagePng + && let Some(dib) = + dibv5_from_png(bytes).and_then(|dib| OwnedGlobal::from_bytes(&dib)) + { + formats.push((CF_DIBV5, dib)); + } + } + let mut clipboard = OpenedClipboard::open().ok_or(Unavailable::Platform)?; + if clipboard.replace(formats) { + Ok(()) + } else { + Err(Unavailable::Platform) + } + } +} + +// ─── everything else: `wl-clipboard`, `xclip`, or `xsel` (reading text) ───── +#[cfg(not(any(target_os = "macos", windows)))] +mod platform { + use core::ffi::c_char; + use core::sync::atomic::Ordering; + use core::time::Duration; + use std::ffi::CString; + use std::io::Write as _; + + use bun_core::strings; + use bun_jsc::{ + JSObject, JSPropertyIterator, JSValue, JsError, JsResult, PropertyIteratorOptions, + }; + use bun_sys::{Fd, File, O}; + + use crate::api::bun_process::Status as SpawnStatus; + use crate::api::bun_process::sync as spawn_sync; + + use super::{HELPER_TIMEOUT_MS, JSGlobalObject, Mime, Outcome, Ticket, Unavailable}; + + unsafe extern "C" { + /// The script's `process.env`, which holds the writes the native env map does not. + fn Bun__Clipboard__processEnv(global: &JSGlobalObject) -> JSValue; + // The declaration of `api/bun/js_bun_spawn_bindings.rs`. + safe static BUN_DEFAULT_PATH_FOR_SPAWN: *const c_char; + } + + /// What `process.env` holds when the operation is scheduled, taken on the JS + /// thread: the process's C `environ` has neither runtime writes nor `.env` files. + pub(super) struct Env(Vec); + + impl Env { + pub(super) fn snapshot(global: &JSGlobalObject) -> JsResult { + // SAFETY: JS thread with a live global; empty if and only if it threw. + let env = bun_jsc::from_js_host_call(global, || unsafe { + Bun__Clipboard__processEnv(global) + })?; + let Some(object) = env.get_object() else { + return Ok(Env(Vec::new())); + }; + let properties = JSPropertyIterator::init( + global, + JSObject::opaque_ref(object), + PropertyIteratorOptions { + skip_empty_name: false, + include_value: true, + }, + )?; + let mut entries = Vec::with_capacity(properties.len); + while let Some((key, value)) = properties.next()? { + if value.is_undefined() { + continue; + } + let value = value.to_bun_string(global)?; + let mut line = Vec::new(); + write!(&mut line, "{key}={value}").map_err(|_| JsError::OutOfMemory)?; + // An entry with a NUL cannot be passed to a child. + if let Ok(line) = CString::new(line) { + entries.push(line); + } + } + Ok(Env(entries)) + } + + fn get(&self, name: &[u8]) -> Option<&[u8]> { + self.0 + .iter() + .find_map(|entry| entry.to_bytes().strip_prefix(name)?.strip_prefix(b"=")) + } + + /// A NULL-terminated `envp` that borrows `self`. + fn envp(&self) -> Vec<*const c_char> { + self.0 + .iter() + .map(|entry| entry.as_ptr()) + .chain(core::iter::once(core::ptr::null())) + .collect() + } + } + + fn is_set(value: Option<&[u8]>) -> bool { + value.is_some_and(|value| !value.is_empty()) + } + + #[derive(Clone, Copy)] + enum Helper { + WlClipboard, + Xclip, + Xsel, + } + + /// The helpers for the displays this process can reach, in preference order. + fn helpers(env: &Env) -> Result, Unavailable> { + let mut list = Vec::with_capacity(3); + if is_set(env.get(b"WAYLAND_DISPLAY")) { + list.push(Helper::WlClipboard); + } + if is_set(env.get(b"DISPLAY")) { + list.extend([Helper::Xclip, Helper::Xsel]); + } + if list.is_empty() { + return Err(Unavailable::NoDisplay); + } + Ok(list) + } + + impl Helper { + fn read_argv(self, mime: Mime) -> Option<&'static [&'static str]> { + Some(match (self, mime) { + // `--type text` matches any text flavour; `--no-newline` adds none. + (Helper::WlClipboard, Mime::TextPlain) => { + &["wl-paste", "--no-newline", "--type", "text"] + } + (Helper::WlClipboard, Mime::TextHtml) => { + &["wl-paste", "--no-newline", "--type", "text/html"] + } + (Helper::WlClipboard, Mime::ImagePng) => { + &["wl-paste", "--no-newline", "--type", "image/png"] + } + (Helper::Xclip, Mime::TextPlain) => &["xclip", "-selection", "clipboard", "-out"], + (Helper::Xclip, Mime::TextHtml) => &[ + "xclip", + "-selection", + "clipboard", + "-t", + "text/html", + "-out", + ], + (Helper::Xclip, Mime::ImagePng) => &[ + "xclip", + "-selection", + "clipboard", + "-t", + "image/png", + "-out", + ], + (Helper::Xsel, Mime::TextPlain) => &["xsel", "--clipboard", "--output"], + (Helper::Xsel, _) => return None, + }) + } + + fn write_argv(self, mime: Mime) -> Option<&'static [&'static str]> { + Some(match (self, mime) { + (Helper::WlClipboard, Mime::TextPlain) => { + &["wl-copy", "--type", "text/plain;charset=utf-8"] + } + (Helper::WlClipboard, Mime::TextHtml) => &["wl-copy", "--type", "text/html"], + (Helper::WlClipboard, Mime::ImagePng) => &["wl-copy", "--type", "image/png"], + (Helper::Xclip, Mime::TextPlain) => &["xclip", "-selection", "clipboard", "-in"], + (Helper::Xclip, Mime::TextHtml) => { + &["xclip", "-selection", "clipboard", "-t", "text/html", "-in"] + } + (Helper::Xclip, Mime::ImagePng) => { + &["xclip", "-selection", "clipboard", "-t", "image/png", "-in"] + } + // xsel exits before its daemon owns the selection, so a write would resolve early. + (Helper::Xsel, _) => return None, + }) + } + + /// Prints the offered types, one per line; xsel has no such mode. + fn targets_argv(self) -> Option<&'static [&'static str]> { + match self { + Helper::WlClipboard => Some(&["wl-paste", "--list-types"]), + Helper::Xclip => { + Some(&["xclip", "-selection", "clipboard", "-t", "TARGETS", "-out"]) + } + Helper::Xsel => None, + } + } + } + + fn offers(targets: &[u8], mime: Mime) -> bool { + strings::split(targets, b"\n").any(|line| { + let line = line.strip_suffix(b"\r").unwrap_or(line); + let essence = strings::split_once_char(line, b';').map_or(line, |(essence, _)| essence); + match mime { + Mime::TextPlain => matches!( + essence, + b"text/plain" | b"UTF8_STRING" | b"STRING" | b"TEXT" + ), + Mime::TextHtml => essence == b"text/html", + Mime::ImagePng => essence == b"image/png", + } + }) + } + + enum HelperRun { + NotInstalled, + Succeeded(Vec), + /// `clean`: the helper said nothing (of that type) is copied. + Failed { + clean: bool, + }, + } + + /// How xclip ("target … not available") and wl-paste say nothing is copied. + const NOTHING_COPIED: [&[u8]; 4] = [ + b"not available", + b"No selection", + b"Nothing is copied", + b"No suitable type", + ]; + + /// 126 and 127 are how a wrapper script says it could not run the program. A + /// helper that a signal ended has failed. The time limit ends it with one. + fn classify(result: spawn_sync::Result) -> HelperRun { + if result.status.is_ok() { + return HelperRun::Succeeded(result.stdout); + } + let SpawnStatus::Exited(exited) = result.status else { + return HelperRun::Failed { clean: false }; + }; + match exited.code { + 126 | 127 => HelperRun::NotInstalled, + _ => HelperRun::Failed { + clean: NOTHING_COPIED + .iter() + .any(|message| strings::contains(&result.stderr, message)), + }, + } + } + + /// Runs one helper. It has a time limit because a hung X11 selection owner + /// blocks it forever; the spawn ends it, and what it started, at the limit. + fn run( + argv: &[&str], + stdin: Option, + capture: bool, + env: &Env, + ticket: &Ticket, + ) -> Result { + // A VM that is stopping takes no answer, so a read gets no further run. + // A write is still of use, so its next candidate runs. + if capture && !ticket.script_allowed() { + return Err(Unavailable::Platform); + } + // An unset PATH is the system's default one, as in sh. An empty one has nothing. + let path = env.get(b"PATH").unwrap_or_else(|| { + // SAFETY: a NUL-terminated static C string. + unsafe { bun_core::ffi::cstr(BUN_DEFAULT_PATH_FOR_SPAWN) }.to_bytes() + }); + let mut path_buf = bun_paths::path_buffer_pool::get(); + let Some(program) = bun_which::which(&mut path_buf, path, b"", argv[0].as_bytes()) else { + return Ok(HelperRun::NotInstalled); + }; + let program = program.as_bytes(); + let output = if capture { + spawn_sync::SyncStdio::Buffer + } else { + spawn_sync::SyncStdio::Ignore + }; + let envp = env.envp(); + let result = spawn_sync::spawn(&spawn_sync::Options { + argv: core::iter::once(program) + .chain(argv[1..].iter().map(|word| word.as_bytes())) + .map(Box::from) + .collect(), + cwd: Box::from(b".".as_slice()), + stdin: stdin.map_or(spawn_sync::SyncStdio::Ignore, spawn_sync::SyncStdio::Fd), + // Not for writes: a helper that daemonizes keeps its output open. + stdout: output, + stderr: output, + envp: Some(envp.as_ptr()), + // A pool thread must not arm the process-wide signal forwarder. + forward_signals: false, + timeout: Some(Duration::from_millis( + HELPER_TIMEOUT_MS.load(Ordering::Relaxed), + )), + // A read is of no use once this process is gone. A write still is: + // its helper goes on to hand the data to the daemon it forks. + linux_pdeathsig: capture.then_some(libc::SIGKILL as u8), + ..Default::default() + }); + match result { + Ok(Ok(result)) => Ok(classify(result)), + // This candidate cannot be run. The next one may. + Ok(Err(error)) + if matches!( + error.get_errno(), + bun_sys::E::ENOENT | bun_sys::E::ENOTDIR | bun_sys::E::EACCES + ) => + { + Ok(HelperRun::NotInstalled) + } + Ok(Err(error)) => Err(Unavailable::Spawn(error)), + Err(_) => Err(Unavailable::Platform), + } + } + + enum Answer { + NotInstalled, + Failed, + Present(Vec<(Mime, Vec)>), + } + + /// Firefox serves `text/html` as UTF-16 with a byte order mark. + fn html_to_utf8(bytes: Vec) -> Vec { + let (rest, big_endian) = match bytes.as_slice() { + [0xFF, 0xFE, rest @ ..] => (rest, false), + [0xFE, 0xFF, rest @ ..] => (rest, true), + _ => return bytes, + }; + let units: Vec = rest + .as_chunks::<2>() + .0 + .iter() + .map(|pair| { + if big_endian { + u16::from_be_bytes(*pair) + } else { + u16::from_le_bytes(*pair) + } + }) + .collect(); + String::from_utf16_lossy(&units).into_bytes() + } + + fn read_one( + helper: Helper, + mime: Mime, + env: &Env, + ticket: &Ticket, + ) -> Result { + let Some(argv) = helper.read_argv(mime) else { + return Ok(Answer::NotInstalled); + }; + Ok(match run(argv, None, true, env, ticket)? { + HelperRun::NotInstalled => Answer::NotInstalled, + HelperRun::Failed { clean: false } => Answer::Failed, + HelperRun::Failed { clean: true } => Answer::Present(Vec::new()), + // An absent type reads as nothing; only text is ever deliberately empty. + HelperRun::Succeeded(bytes) if bytes.is_empty() && mime != Mime::TextPlain => { + Answer::Present(Vec::new()) + } + HelperRun::Succeeded(bytes) if mime == Mime::TextHtml => { + Answer::Present(vec![(mime, html_to_utf8(bytes))]) + } + HelperRun::Succeeded(bytes) => Answer::Present(vec![(mime, bytes)]), + }) + } + + /// Asks the selection owner what it offers, then reads only those types. + fn read_offered( + helper: Helper, + types: &[Mime], + env: &Env, + ticket: &Ticket, + ) -> Result { + let Some(argv) = helper.targets_argv() else { + return read_one(helper, Mime::TextPlain, env, ticket); + }; + let targets = match run(argv, None, true, env, ticket)? { + HelperRun::NotInstalled => return Ok(Answer::NotInstalled), + HelperRun::Failed { clean: false } => return Ok(Answer::Failed), + HelperRun::Failed { clean: true } => return Ok(Answer::Present(Vec::new())), + HelperRun::Succeeded(targets) => targets, + }; + let mut present = Vec::new(); + for &mime in types.iter().filter(|&&mime| offers(&targets, mime)) { + match read_one(helper, mime, env, ticket)? { + Answer::Present(mut read) => present.append(&mut read), + // An offered type that cannot be delivered is a failed read, not an absent one. + Answer::Failed | Answer::NotInstalled => return Ok(Answer::Failed), + } + } + Ok(Answer::Present(present)) + } + + /// The first helper that reaches the clipboard answers for it. + pub(super) fn read_types(types: &[Mime], env: &Env, ticket: &Ticket) -> Outcome { + let mut ran = false; + for helper in helpers(env)? { + let answer = match types { + [mime] => read_one(helper, *mime, env, ticket)?, + _ => read_offered(helper, types, env, ticket)?, + }; + match answer { + Answer::Present(present) => return Ok(present), + Answer::NotInstalled => {} + Answer::Failed => ran = true, + } + } + Err(if ran { + Unavailable::HelperFailed + } else { + Unavailable::NoHelper + }) + } + + pub(super) fn write_types( + items: &[(Mime, Vec)], + env: &Env, + ticket: &Ticket, + ) -> Result<(), Unavailable> { + // WebCore passes exactly one representation on this backend. + let [(mime, bytes)] = items else { + return Err(Unavailable::Platform); + }; + let helpers = helpers(env)?; + let payload = payload(bytes).ok_or(Unavailable::Platform)?; + let mut ran = false; + for helper in helpers { + let Some(argv) = helper.write_argv(*mime) else { + continue; + }; + payload.seek_to(0).map_err(|_| Unavailable::Platform)?; + match run(argv, Some(payload.handle), false, env, ticket)? { + HelperRun::Succeeded(_) => return Ok(()), + HelperRun::NotInstalled => {} + HelperRun::Failed { .. } => ran = true, + } + } + Err(if ran { + Unavailable::HelperFailed + } else { + Unavailable::NoHelper + }) + } + + /// The payload behind an fd that has no name anyone else can open. + fn payload(bytes: &[u8]) -> Option { + let file = open_anonymous()?; + file.write_all(bytes).ok()?; + Some(file) + } + + #[cfg(any(target_os = "linux", target_os = "android"))] + fn open_anonymous() -> Option { + if bun_sys::can_use_memfd() + && let Ok(fd) = + bun_sys::memfd_create(c"bun-clipboard", bun_sys::MemfdFlags::NonExecutable) + { + return Some(File::from_fd(fd)); + } + open_unlinked_temp_file() + } + + #[cfg(not(any(target_os = "linux", target_os = "android")))] + fn open_anonymous() -> Option { + open_unlinked_temp_file() + } + + /// A private temp file, unlinked before any helper runs. + fn open_unlinked_temp_file() -> Option { + let mut name_buf = [0u8; 64]; + let name = bun_paths::fs::FileSystem::tmpname( + b"bun-clipboard", + &mut name_buf, + bun_core::fast_random(), + ) + .ok()?; + let mut path = bun_resolver::fs::RealFS::tmpdir_path().to_vec(); + if path.last() != Some(&b'/') { + path.push(b'/'); + } + path.extend_from_slice(name.as_bytes()); + let file = File::openat( + Fd::cwd(), + &path, + O::RDWR | O::CREAT | O::EXCL | O::CLOEXEC, + 0o600, + ) + .ok()?; + let len = path.len(); + path.push(0); + bun_sys::unlink(bun_core::ZStr::from_buf(&path, len)).ok()?; + Some(file) + } +} diff --git a/src/spawn/process.rs b/src/spawn/process.rs index 1de6cc81d2c4..82907fc76bf1 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -2310,6 +2310,22 @@ mod spawn_process_body { pub use_execve_on_macos: bool, pub argv0: Option<*const c_char>, + /// POSIX: arm the process-wide SIGINT/SIGTERM forwarder (not from work-pool threads). + pub forward_signals: bool, + + /// How long the child may run. It leads its own process group, which gets + /// SIGKILL when the time is up. The wait ends when the child exits, not at + /// EOF of a captured pipe that something it started still holds. + /// + /// For a child that does not use the terminal: the group is a background + /// one, so a read from the terminal stops the child. The no-orphans wait + /// does not run for it. + #[cfg(unix)] + pub timeout: Option, + + /// Linux: the signal the child gets when the thread that spawned it exits. + pub linux_pdeathsig: Option, + #[cfg(windows)] pub windows: WindowsOptions, } @@ -2319,6 +2335,8 @@ mod spawn_process_body { Inherit, Ignore, Buffer, + /// For `stdin` only: the caller's fd, which it keeps open and closes. + Fd(Fd), } impl SyncStdio { @@ -2326,6 +2344,7 @@ mod spawn_process_body { match self { SyncStdio::Inherit => SpawnOptionsStdio::inherit(), SyncStdio::Ignore => SpawnOptionsStdio::ignore(), + SyncStdio::Fd(fd) => SpawnOptionsStdio::Pipe(fd), SyncStdio::Buffer => { #[cfg(windows)] { @@ -2375,6 +2394,10 @@ mod spawn_process_body { envp: None, use_execve_on_macos: false, argv0: None, + forward_signals: true, + #[cfg(unix)] + timeout: None, + linux_pdeathsig: None, #[cfg(windows)] windows: Default::default(), } @@ -2383,6 +2406,12 @@ mod spawn_process_body { impl Options { pub(crate) fn to_spawn_options(&self, new_process_group: bool) -> SpawnOptions { + // The wait drains and closes the fds of these two as its own. + debug_assert!( + !matches!(self.stdout, SyncStdio::Fd(_)) + && !matches!(self.stderr, SyncStdio::Fd(_)), + "SyncStdio::Fd is for stdin" + ); SpawnOptions { stdin: self.stdin.to_stdio(), stdout: self.stdout.to_stdio(), @@ -2394,6 +2423,7 @@ mod spawn_process_body { stream: false, argv0: self.argv0, new_process_group, + linux_pdeathsig: self.linux_pdeathsig, #[cfg(windows)] windows: self.windows.clone(), ..Default::default() @@ -3128,13 +3158,18 @@ mod spawn_process_body { } } - Bun__currentSyncPID.store(0, core::sync::atomic::Ordering::Relaxed); - let _signals = SignalForwarding::register(); + let forward_signals = options.forward_signals; + if forward_signals { + Bun__currentSyncPID.store(0, core::sync::atomic::Ordering::Relaxed); + } + let _signals = forward_signals.then(SignalForwarding::register); + // A time limit is for the child and for what it started. + let own_group = no_orphans || options.timeout.is_some(); // SAFETY: caller-built argv/envp are null-terminated C-string // arrays with argv[0] non-null; valid for this call. let process = match unsafe { - spawn_process_posix(&options.to_spawn_options(no_orphans), argv, envp) + spawn_process_posix(&options.to_spawn_options(own_group), argv, envp) }? { Err(err) => return Ok(Err(err)), Ok(proces) => proces, @@ -3142,14 +3177,16 @@ mod spawn_process_body { // Negative → kill() in the C++ signal forwarder targets the pgroup, so // a SIGTERM/SIGINT delivered to `bun run` reaches every descendant // that hasn't `setsid()`-escaped. - Bun__currentSyncPID.store( - if no_orphans { - -i64::from(process.pid) - } else { - i64::from(process.pid) - }, - core::sync::atomic::Ordering::Relaxed, - ); + if forward_signals { + Bun__currentSyncPID.store( + if own_group { + -i64::from(process.pid) + } else { + i64::from(process.pid) + }, + core::sync::atomic::Ordering::Relaxed, + ); + } let mut jc = JobControl { prev: 0, @@ -3205,7 +3242,9 @@ mod spawn_process_body { } } }); - Bun__sendPendingSignalIfNecessary(); + if forward_signals { + Bun__sendPendingSignalIfNecessary(); + } let mut out: [Vec; 2] = [Vec::new(), Vec::new()]; let mut out_fds: [Fd; 2] = [ @@ -3238,6 +3277,7 @@ mod spawn_process_body { // below is required. let status: Status = 'blk: { if no_orphans + && options.timeout.is_none() && (cfg!(any(target_os = "linux", target_os = "android")) || cfg!(target_os = "macos")) { @@ -3284,8 +3324,57 @@ mod spawn_process_body { // plain poll() loop so `.buffer` stdio still drains instead // of being dropped (or deadlocking) in a blind `wait4()`. } - while out_fds_to_wait_for[0] != Fd::INVALID || out_fds_to_wait_for[1] != Fd::INVALID + // With a time limit the loop ends when the child does. A pidfd wakes + // the poll for that. Without one only a look finds out, so the looks + // start 1 ms apart and back off to 100 ms. + #[cfg(any(target_os = "linux", target_os = "android"))] + let exit_fd = process.pidfd; + #[cfg(not(any(target_os = "linux", target_os = "android")))] + let exit_fd: Option = None; + let deadline = options + .timeout + .map(|limit| std::time::Instant::now() + limit); + let mut next_look_ms: c_int = 1; + while deadline.is_some() + || out_fds_to_wait_for[0] != Fd::INVALID + || out_fds_to_wait_for[1] != Fd::INVALID { + let mut poll_timeout: c_int = -1; + if let Some(deadline) = deadline { + // The exit before the clock: a child that is done is not late. + let mut exited = Status::from( + process.pid, + &posix_spawn::wait4(process.pid, libc::WNOHANG as u32, None), + ); + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if exited.is_none() && left.is_zero() { + // The group, and the child by pid in case it left the group. + let _ = kill(-process.pid, libc::SIGKILL); + let _ = kill(process.pid, libc::SIGKILL); + exited = Some(reap_child(process.pid)); + } + if let Some(status) = exited { + for i in 0..2 { + if let Some(err) = drain_fd( + &mut out_fds_to_wait_for[i], + &mut out_fds[i], + &mut out[i], + ) { + // Reaped, so there is nothing left to signal. + cleanup_spawn_posix(&mut out, out_fds, &process, true); + return Ok(Err(err)); + } + } + break 'blk status; + } + poll_timeout = c_int::try_from(left.as_millis()) + .unwrap_or(c_int::MAX) + .max(1); + if exit_fd.is_none() { + poll_timeout = poll_timeout.min(next_look_ms); + next_look_ms = (next_look_ms * 2).min(100); + } + } for i in 0..2 { if let Some(err) = drain_fd(&mut out_fds_to_wait_for[i], &mut out_fds[i], &mut out[i]) @@ -3295,7 +3384,7 @@ mod spawn_process_body { } } - let mut poll_fds_buf: [libc::pollfd; 2] = + let mut poll_fds_buf: [libc::pollfd; 3] = // SAFETY: zeroed pollfd is valid unsafe { bun_core::ffi::zeroed_unchecked() }; let mut poll_len: usize = 0; @@ -3310,12 +3399,22 @@ mod spawn_process_body { }; poll_len += 1; } - if poll_len == 0 { + if let (Some(_), Some(fd)) = (deadline, exit_fd) { + poll_fds_buf[poll_len] = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + poll_len += 1; + } + if poll_len == 0 && deadline.is_none() { break; } // SAFETY: valid pollfd array - let rc = unsafe { libc::poll(poll_fds_buf.as_mut_ptr(), poll_len as _, -1) }; + let rc = unsafe { + libc::poll(poll_fds_buf.as_mut_ptr(), poll_len as _, poll_timeout) + }; match bun_sys::get_errno(rc as isize) { bun_sys::E::SUCCESS => {} bun_sys::E::EAGAIN | bun_sys::E::EINTR => continue, diff --git a/src/sys/windows/mod.rs b/src/sys/windows/mod.rs index d2eac80990ea..f026f9cb13a2 100644 --- a/src/sys/windows/mod.rs +++ b/src/sys/windows/mod.rs @@ -29,8 +29,11 @@ pub use bun_windows_sys::ws2_32; pub mod kernel32 { use super::{ BOOL, CONDITION_VARIABLE, DWORD, FileNotifyChangeFilter, HANDLE, LPCWSTR, LPOVERLAPPED, - LPOVERLAPPED_COMPLETION_ROUTINE, OVERLAPPED, SRWLOCK, ULONG, ULONG_PTR, + LPOVERLAPPED_COMPLETION_ROUTINE, LPVOID, OVERLAPPED, SRWLOCK, UINT, ULONG, ULONG_PTR, }; + + pub const GMEM_MOVEABLE: UINT = 0x0002; + pub const GMEM_ZEROINIT: UINT = 0x0040; pub use bun_windows_sys::externs::SetEndOfFile; pub use bun_windows_sys::externs::{GetConsoleMode, GetExitCodeProcess, SetConsoleMode}; pub use bun_windows_sys::kernel32::*; @@ -89,6 +92,40 @@ pub mod kernel32 { /// No preconditions; reads the calling thread's ID. pub safe fn GetCurrentThreadId() -> DWORD; + + // safe: by-value arguments. + pub safe fn Sleep(dwMilliseconds: DWORD); + + // ── movable global memory ── + // safe: by-value arguments; failure is NULL / 0. + pub safe fn GlobalAlloc(uFlags: UINT, dwBytes: usize) -> HANDLE; + pub safe fn GlobalSize(hMem: HANDLE) -> usize; + pub fn GlobalFree(hMem: HANDLE) -> HANDLE; + pub fn GlobalLock(hMem: HANDLE) -> LPVOID; + pub fn GlobalUnlock(hMem: HANDLE) -> BOOL; + } +} + +/// https://learn.microsoft.com/en-us/windows/win32/dataxchg/clipboard +pub mod user32 { + use super::{BOOL, DWORD, HANDLE, UINT}; + use core::ffi::c_char; + + pub const CF_UNICODETEXT: UINT = 13; + + #[link(name = "user32")] + unsafe extern "system" { + // safe: by-value arguments; failure is FALSE / NULL. + pub safe fn OpenClipboard(hWndNewOwner: HANDLE) -> BOOL; + pub safe fn GetClipboardData(uFormat: UINT) -> HANDLE; + pub safe fn IsClipboardFormatAvailable(uFormat: UINT) -> BOOL; + pub safe fn GetClipboardSequenceNumber() -> DWORD; + // Each of these can free memory that `GetClipboardData` handles point to. + pub fn CloseClipboard() -> BOOL; + pub fn EmptyClipboard() -> BOOL; + /// On success the system owns `hMem`. + pub fn SetClipboardData(uFormat: UINT, hMem: HANDLE) -> HANDLE; + pub fn RegisterClipboardFormatA(lpszFormat: *const c_char) -> UINT; } } @@ -163,7 +200,6 @@ pub use bun_windows_sys::OBJECT_ATTRIBUTES; pub use bun_windows_sys::STANDARD_RIGHTS_READ; pub use bun_windows_sys::advapi32; pub use bun_windows_sys::kernel32::SetConsoleCtrlHandler; -pub use bun_windows_sys::user32; pub use bun_windows_sys::{CONSOLE_SCREEN_BUFFER_INFO, SMALL_RECT}; pub use bun_windows_sys::{CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT}; pub use bun_windows_sys::{DELETE, GENERIC_READ, GENERIC_WRITE, SYNCHRONIZE}; diff --git a/src/threading/Mutex.rs b/src/threading/Mutex.rs index af5baded3ae5..f9663c5211d2 100644 --- a/src/threading/Mutex.rs +++ b/src/threading/Mutex.rs @@ -49,7 +49,6 @@ impl Mutex { /// Tries to acquire the mutex without blocking the caller's thread. /// Returns `false` if the calling thread would have to block to acquire it. /// Otherwise, returns `true` and the caller should `unlock()` the Mutex to release it. - #[cfg(debug_assertions)] pub fn try_lock(&self) -> bool { self.impl_.try_lock() } @@ -234,7 +233,6 @@ unsafe impl Send for WindowsImpl {} unsafe extern "system" { safe fn AcquireSRWLockExclusive(lock: &core::cell::UnsafeCell); // Returns BOOLEAN (u8), not BOOL — compare against 0, not the i32 `FALSE`. - #[cfg(debug_assertions)] safe fn TryAcquireSRWLockExclusive( lock: &core::cell::UnsafeCell, ) -> u8; @@ -248,7 +246,6 @@ impl WindowsImpl { } } - #[cfg(debug_assertions)] fn try_lock(&self) -> bool { TryAcquireSRWLockExclusive(&self.srwlock) != 0 } @@ -299,7 +296,6 @@ pub(crate) struct OsUnfairLock { // `os_unfair_lock_unlock` takes the address instead: see `Mutex::unlock_raw`. #[cfg(target_vendor = "apple")] unsafe extern "C" { - #[cfg(debug_assertions)] safe fn os_unfair_lock_trylock(lock: &core::cell::UnsafeCell) -> bool; safe fn os_unfair_lock_lock(lock: &core::cell::UnsafeCell); fn os_unfair_lock_unlock(lock: *mut OsUnfairLock); @@ -313,7 +309,6 @@ impl DarwinImpl { } } - #[cfg(debug_assertions)] fn try_lock(&self) -> bool { os_unfair_lock_trylock(&self.oul) } diff --git a/test/js/web/clipboard/clipboard.test.ts b/test/js/web/clipboard/clipboard.test.ts new file mode 100644 index 000000000000..16a9d07fa015 --- /dev/null +++ b/test/js/web/clipboard/clipboard.test.ts @@ -0,0 +1,2251 @@ +// The async Clipboard API: https://w3c.github.io/clipboard-apis/ +// Tests that reach the system clipboard replace what is on it, so they run on +// CI or with BUN_TEST_SYSTEM_CLIPBOARD=1. Where no clipboard is reachable +// (headless Linux) they assert the "NotAllowedError" rejection instead. +import { dlopen, FFIType, ptr, toBuffer } from "bun:ffi"; +import { heapStats } from "bun:jsc"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isCI, isLinux, isMacOS, isWindows, tempDir } from "harness"; +import { once } from "node:events"; +import { chmodSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { inflateSync } from "node:zlib"; + +// A valid 1x1 transparent PNG; used to prove binary representations survive +// the platform round-trip. +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", +); + +// Asserts that `promise` rejects with a DOMException of exactly `name`. +async function expectDOMException(promise: Promise, name: string) { + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(DOMException); + expect((error as DOMException).name).toBe(name); +} + +const systemClipboard = isCI || !!process.env.BUN_TEST_SYSTEM_CLIPBOARD; + +// A macOS agent with no GUI session has no pasteboard server, and then Apple's +// own tools cannot carry a value either. That is the machine, not the backend. +function pasteboardToolsRoundTrip() { + try { + const token = `bun clipboard probe ${process.pid}`; + if (Bun.spawnSync({ cmd: ["pbcopy"], stdin: Buffer.from(token) }).exitCode !== 0) return false; + const paste = Bun.spawnSync({ cmd: ["pbpaste"] }); + return paste.exitCode === 0 && paste.stdout.toString() === token; + } catch { + return false; + } +} +const machineHasClipboard = systemClipboard && (isWindows || (isMacOS && pasteboardToolsRoundTrip())); + +let clipboardReachable = false; +beforeAll(async () => { + if (!systemClipboard) return; + const failure = await navigator.clipboard.readText().then( + () => null, + (e: unknown) => e, + ); + clipboardReachable = failure === null; + if (isCI && machineHasClipboard && !clipboardReachable) { + throw new Error(`the system clipboard is not reachable on this CI lane: ${failure}`); + } +}); + +describe("interface shape", () => { + test("navigator.clipboard exists and is the [SameObject] Clipboard singleton", () => { + expect(navigator.clipboard).toBeDefined(); + expect(navigator.clipboard).toBeInstanceOf(Clipboard); + expect(navigator.clipboard).toBeInstanceOf(EventTarget); + // [SameObject] + expect(navigator.clipboard).toBe(navigator.clipboard); + // `clipboard` is a getter on the navigator object, like its other props. + expect(typeof Object.getOwnPropertyDescriptor(navigator, "clipboard")?.get).toBe("function"); + }); + + test("Clipboard is a global interface object extending EventTarget", () => { + expect(typeof Clipboard).toBe("function"); + expect(Clipboard.name).toBe("Clipboard"); + expect(globalThis.Clipboard).toBe(Clipboard); + expect(Object.getPrototypeOf(Clipboard.prototype)).toBe(EventTarget.prototype); + }); + + test("new Clipboard() throws a TypeError", () => { + // Same wording as Bun's other non-constructable WebCore classes + // (e.g. `new Performance()`). + // @ts-expect-error: Clipboard has no public constructor. + expect(() => new Clipboard()).toThrow(TypeError); + // @ts-expect-error: Clipboard has no public constructor. + expect(() => new Clipboard()).toThrow("Illegal constructor"); + }); + + test("prototype members are enumerable functions with the right arity", () => { + // WebIDL: interface members are enumerable, unlike plain JS class methods. + expect(Object.keys(Clipboard.prototype)).toEqual(["readText", "writeText", "read", "write"]); + expect(Clipboard.prototype.readText.length).toBe(0); + expect(Clipboard.prototype.writeText.length).toBe(1); + expect(Clipboard.prototype.read.length).toBe(0); + expect(Clipboard.prototype.write.length).toBe(1); + }); + + test("Symbol.toStringTag is 'Clipboard'", () => { + expect(Object.prototype.toString.call(navigator.clipboard)).toBe("[object Clipboard]"); + expect(Object.getOwnPropertyDescriptor(Clipboard.prototype, Symbol.toStringTag)).toEqual({ + value: "Clipboard", + writable: false, + enumerable: false, + configurable: true, + }); + }); + + test("readText()/writeText() return Promises and reject (not throw) on a bad receiver", async () => { + // WebIDL: a Promise-returning operation converts a failed brand check + // into a rejection, never a synchronous throw. + const detached = Clipboard.prototype.readText.call({} as Clipboard); + expect(detached).toBeInstanceOf(Promise); + await expect(detached).rejects.toThrow(TypeError); + await expect(Clipboard.prototype.writeText.call({} as Clipboard, "x")).rejects.toThrow(TypeError); + await expect(Clipboard.prototype.read.call({} as Clipboard)).rejects.toThrow(TypeError); + await expect(Clipboard.prototype.write.call({} as Clipboard, [])).rejects.toThrow(TypeError); + }); + + test("writeText() argument handling follows WebIDL", async () => { + // @ts-expect-error: writeText requires 1 argument. + await expect(navigator.clipboard.writeText()).rejects.toThrow(TypeError); + // The DOMString conversion of a Symbol throws before any platform code runs. + await expect(navigator.clipboard.writeText(Symbol("x") as unknown as string)).rejects.toThrow(TypeError); + }); + + // The bytecode linker reifies bare globals before any statement runs, so + // the `Clipboard` lookup-table entry must be materializable at link time. + test("bare `Clipboard` identifier as the first statement of a process", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "console.log(Clipboard.prototype === navigator.clipboard.constructor.prototype)"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // `stderr` is unconstrained (debug builds emit benign warnings) but is + // part of the asserted object so a failure diff shows it. + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: "true", + stderr: expect.any(String), + exitCode: 0, + }); + }); + + // WebIDL: the interface objects are writable globals, so polyfills and test + // mocks can replace them. Last in the suite: it swaps the real class out. + test("globalThis.Clipboard is replaceable", () => { + const original = Clipboard; + try { + // @ts-expect-error: intentionally assigning a non-Clipboard value. + globalThis.Clipboard = 123; + expect(globalThis.Clipboard).toBe(123); + } finally { + globalThis.Clipboard = original; + } + expect(globalThis.Clipboard).toBe(original); + expect(navigator.clipboard).toBeInstanceOf(original); + }); +}); + +describe("ClipboardItem", () => { + test("is a constructible global with the right shape", () => { + expect(typeof ClipboardItem).toBe("function"); + expect(globalThis.ClipboardItem).toBe(ClipboardItem); + // WebIDL: the second constructor argument is optional. + expect(ClipboardItem.length).toBe(1); + const item = new ClipboardItem({ "text/plain": "hello" }); + expect(item).toBeInstanceOf(ClipboardItem); + expect(Object.prototype.toString.call(item)).toBe("[object ClipboardItem]"); + expect(typeof ClipboardItem.supports).toBe("function"); + }); + + test("constructor validates its arguments like the spec", () => { + // @ts-expect-error: requires an items record. + expect(() => new ClipboardItem()).toThrow(TypeError); + expect(() => new ClipboardItem({})).toThrow(TypeError); + expect(() => new ClipboardItem({ "not a mime": "x" })).toThrow(TypeError); + expect(() => new ClipboardItem({ "text/plain": "x" }, { presentationStyle: "nope" as never })).toThrow(TypeError); + // WebIDL: a non-null, non-undefined, non-object options dictionary throws. + expect(() => new ClipboardItem({ "text/plain": "x" }, 42 as never)).toThrow(TypeError); + expect(new ClipboardItem({ "text/plain": "x" }, null as never).presentationStyle).toBe("unspecified"); + // WebIDL record semantics: exotic (Proxy) records and non-enumerable keys. + expect(new ClipboardItem(new Proxy({ "text/plain": "x" }, {})).types).toEqual(["text/plain"]); + const items = Object.defineProperty({ "text/plain": "x" }, "not a mime", { value: "y", enumerable: false }); + expect(new ClipboardItem(items).types).toEqual(["text/plain"]); + // mimesniff §4.4/§4.5: `types` reports the serialization of the parsed + // MIME type, parameters included, as in Chrome. + const parameterized = new ClipboardItem({ "Text/Plain; charset=utf-8": "x" }); + expect(parameterized.types).toEqual(["text/plain;charset=utf-8"]); + const padded = new ClipboardItem({ " \ttext/plain\r\n": "y" }); + expect(padded.types).toEqual(["text/plain"]); + // Only HTTP whitespace is trimmed (mimesniff section 4.4): a form feed is + // not, so it stays in the type token and fails the token check. Inside + // the parameters it makes that name invalid, dropping the parameter. + expect(() => new ClipboardItem({ "\ftext/plain": "x" })).toThrow(TypeError); + expect(ClipboardItem.supports("\ftext/plain")).toBe(false); + expect(new ClipboardItem({ "text/plain;\fcharset=utf-8": "x" }).types).toEqual(["text/plain"]); + // Distinct serializations are distinct representations. + const twoReps = new ClipboardItem({ "text/plain": "a", "text/plain;charset=utf-8": "b" }); + expect(twoReps.types).toEqual(["text/plain", "text/plain;charset=utf-8"]); + expect(() => new ClipboardItem({ "text/": "x" })).toThrow(TypeError); + // Two spellings of one serialization are one representation, not two. + expect(() => new ClipboardItem({ "text/plain": "a", " text/plain ": "b" })).toThrow(TypeError); + // supports() matches by essence. + expect(ClipboardItem.supports("text/plain;charset=utf-8")).toBe(true); + // Spec "web " custom formats are not implemented. The error says so + // instead of calling the key malformed. + expect(() => new ClipboardItem({ "web text/csv": "a,b" })).toThrow( + new TypeError('Web custom formats like "web text/csv" are not supported'), + ); + }); + + test("getType() matches by the parsed MIME essence", async () => { + const item = new ClipboardItem({ " Text/Plain ; charset=utf-8": "essence" }); + expect(await (await item.getType("text/plain")).text()).toBe("essence"); + expect(await (await item.getType(" text/plain;charset=utf-8 ")).text()).toBe("essence"); + }); + + // Two same-essence entries are stored distinctly, so each must stay + // reachable: the exact serialization wins before the essence fallback. + test("getType() prefers an exact serialization match over the essence", async () => { + const twoReps = new ClipboardItem({ "text/plain": "a", "text/plain;charset=utf-8": "b" }); + expect(await (await twoReps.getType("text/plain")).text()).toBe("a"); + expect(await (await twoReps.getType(" Text/Plain ;charset=utf-8")).text()).toBe("b"); + // Platform formats carry no parameters, so writing both would silently + // overwrite one; the write rejects before touching the OS. + await expectDOMException(navigator.clipboard.write([twoReps]), "NotAllowedError"); + }); + + test("MIME types are normalized to their lowercased serialization", async () => { + const item = new ClipboardItem({ "TeXt/PlAiN": "upper" }); + expect(item.types).toEqual(["text/plain"]); + expect(await (await item.getType("text/plain")).text()).toBe("upper"); + expect(await (await item.getType("TEXT/PLAIN")).text()).toBe("upper"); + expect(ClipboardItem.supports("TEXT/PLAIN")).toBe(true); + // Two spellings of one type are one representation, not two. + expect(() => new ClipboardItem({ "text/plain": "a", "TEXT/PLAIN": "b" })).toThrow(TypeError); + }); + + test("types is frozen and preserves insertion order; presentationStyle defaults", () => { + const item = new ClipboardItem({ "text/plain": "a", "text/html": "a" }, { presentationStyle: "inline" }); + expect(item.types).toEqual(["text/plain", "text/html"]); + expect(Object.isFrozen(item.types)).toBe(true); + // WebIDL FrozenArray [SameObject]: the same JSArray on every get. + expect(item.types).toBe(item.types); + expect(item.presentationStyle).toBe("inline"); + expect(new ClipboardItem({ "text/plain": "a" }).presentationStyle).toBe("unspecified"); + }); + + test("getType() resolves Blobs of the requested type from strings, Blobs, and promises", async () => { + const item = new ClipboardItem({ + "text/plain": "as a string", + "text/html": Promise.resolve("as a promise"), + // A Blob whose declared type differs is rewrapped as the requested type. + "image/png": new Blob([PNG_1X1], { type: "application/octet-stream" }), + }); + // Bun's Blob normalizes text MIME types with a charset parameter, so the + // returned types are asserted exactly as Blob reports them. + const plain = await item.getType("text/plain"); + expect(plain).toBeInstanceOf(Blob); + // Spec: getType() returns a Blob, not a File (and browsers agree). A File + // input must not leak its File-ness or name through the dupe either. + expect(plain).not.toBeInstanceOf(File); + const fromFile = await new ClipboardItem({ + "text/plain": new File(["x"], "f.txt", { type: "text/plain" }), + }).getType("text/plain"); + expect(fromFile).not.toBeInstanceOf(File); + expect((fromFile as File).name).toBeUndefined(); + expect(plain.type).toBe("text/plain;charset=utf-8"); + expect(await plain.text()).toBe("as a string"); + const html = await item.getType("text/html"); + expect(await html.text()).toBe("as a promise"); + expect(html.type).toBe("text/html;charset=utf-8"); + const png = await item.getType("image/png"); + expect(png.type).toBe("image/png"); + expect(Buffer.from(await png.arrayBuffer()).equals(PNG_1X1)).toBe(true); + }); + + // The reaction getType() installs must keep the item (and so its stored + // DOMPromise) alive until the representation settles; otherwise a temporary + // item is collected first and the await spuriously rejects. + test("an in-flight getType() keeps its item alive across GC", async () => { + const { promise, resolve } = Promise.withResolvers(); + const pending = new ClipboardItem({ "text/plain": promise }).getType("text/plain"); + Bun.gc(true); + Bun.gc(true); + resolve("survived"); + const blob = await pending; + expect(await blob.text()).toBe("survived"); + }); + + test("getType() of an absent type rejects with a NotFoundError DOMException", async () => { + const item = new ClipboardItem({ "text/plain": "x" }); + await expectDOMException(item.getType("image/png"), "NotFoundError"); + // The message names the type that was missing. + await expect(item.getType("image/png")).rejects.toThrow('"image/png"'); + }); + + test("getType() forwards the representation's own rejection reason", async () => { + // Same reason write() surfaces for the same failure, rather than a + // flattened AbortError. + const item = new ClipboardItem({ "text/plain": Promise.reject(new Error("nope")) }); + await expect(item.getType("text/plain")).rejects.toThrow("nope"); + }); + + // WebIDL `(DOMString or Blob)`: a non-Blob fulfillment value is ToString'd + // (so `42` → `"42"`, `null` → `"null"`); only uncoercible values reject. + test("getType() coerces non-Blob, non-string data with ToString per WebIDL", async () => { + const item = new ClipboardItem({ + "text/plain": 42 as never, + "text/html": Promise.resolve(true) as never, + "application/json": { toString: () => '{"a":1}' } as never, + }); + expect(await (await item.getType("text/plain")).text()).toBe("42"); + expect(await (await item.getType("text/html")).text()).toBe("true"); + expect(await (await item.getType("application/json")).text()).toBe('{"a":1}'); + // `null` / `undefined` stringify; a Symbol (the one value ToString cannot + // convert) rejects, as does a `toString` that throws. + expect(await (await new ClipboardItem({ "text/plain": null as never }).getType("text/plain")).text()).toBe("null"); + await expect(new ClipboardItem({ "text/plain": Symbol("x") as never }).getType("text/plain")).rejects.toThrow( + TypeError, + ); + const throwing = { + toString() { + throw new Error("nope"); + }, + }; + await expect(new ClipboardItem({ "text/plain": throwing as never }).getType("text/plain")).rejects.toThrow("nope"); + }); + + test("supports() tells the per-platform truth and coerces per WebIDL", () => { + expect(ClipboardItem.supports("text/plain")).toBe(true); + expect(ClipboardItem.supports("image/png")).toBe(true); + expect(ClipboardItem.supports("text/html")).toBe(true); + expect(ClipboardItem.supports("application/x-bun-custom")).toBe(false); + // Browsers answer true for a spec "web " custom format because they can + // write one. Bun cannot, so it does not claim to. + expect(ClipboardItem.supports("web text/html")).toBe(false); + expect(ClipboardItem.supports("web application/x-bun-custom")).toBe(false); + // WebIDL DOMString conversion: stringifiable objects work, Symbols throw, + // and the argument is required. + expect(ClipboardItem.supports({ toString: () => "text/plain" } as unknown as string)).toBe(true); + expect(() => ClipboardItem.supports(Symbol("x") as unknown as string)).toThrow(TypeError); + // @ts-expect-error: the argument is required. + expect(() => ClipboardItem.supports()).toThrow(TypeError); + }); + + test("accessors brand-check their receiver", () => { + const proto = ClipboardItem.prototype; + expect(() => Object.getOwnPropertyDescriptor(proto, "types")!.get!.call({})).toThrow(TypeError); + expect(() => Object.getOwnPropertyDescriptor(proto, "presentationStyle")!.get!.call({})).toThrow(TypeError); + }); + + // Regression for JSClipboardItem missing its destroy() method-table entry: + // without it GC swept the wrapper but never ran ~JSClipboardItem, so the + // impl's DOMPromise stayed in guardedObjects and pinned its JSPromise. + test("collected wrappers release their impl", () => { + Bun.gc(true); + const before = heapStats().objectTypeCounts.Promise || 0; + for (let i = 0; i < 2000; i++) new ClipboardItem({ "text/plain": "x" }); + Bun.gc(true); + Bun.gc(true); + const after = heapStats().objectTypeCounts.Promise || 0; + // Each leaked impl pinned one Promise; without destroy() this grew by ~2000. + expect(after - before).toBeLessThan(200); + }); +}); + +describe("ClipboardEvent", () => { + test("is a constructible Event subclass that can be dispatched synthetically", () => { + expect(typeof ClipboardEvent).toBe("function"); + expect(Object.getPrototypeOf(ClipboardEvent.prototype)).toBe(Event.prototype); + // WebIDL: the event-init argument is optional. + expect(ClipboardEvent.length).toBe(1); + const event = new ClipboardEvent("paste", { bubbles: true }); + expect(event).toBeInstanceOf(ClipboardEvent); + expect(event).toBeInstanceOf(Event); + expect(event.type).toBe("paste"); + expect(event.bubbles).toBe(true); + // Bun has no DataTransfer, so this is always null. + expect(event.clipboardData).toBeNull(); + expect(Object.prototype.toString.call(event)).toBe("[object ClipboardEvent]"); + + const target = new EventTarget(); + const seen: string[] = []; + target.addEventListener("copy", e => { + seen.push((e as ClipboardEvent).type); + }); + target.dispatchEvent(new ClipboardEvent("copy")); + expect(seen).toEqual(["copy"]); + }); + + test("constructor and brand checks reject bad use", () => { + // @ts-expect-error: a type argument is required. + expect(() => new ClipboardEvent()).toThrow(TypeError); + // There is no DataTransfer, so only a null `clipboardData` converts. + expect(new ClipboardEvent("copy", { clipboardData: null }).clipboardData).toBeNull(); + expect(() => new ClipboardEvent("copy", { clipboardData: {} as never })).toThrow(TypeError); + const get = Object.getOwnPropertyDescriptor(ClipboardEvent.prototype, "clipboardData")!.get!; + expect(() => get.call(new Event("copy"))).toThrow(TypeError); + }); +}); + +describe("read / write", () => { + // Everything here rejects during validation, before any OS access, so it is + // deterministic on every platform including headless CI. + test("write() argument validation follows the spec, before touching the OS", async () => { + // @ts-expect-error: write requires 1 argument. + await expect(navigator.clipboard.write()).rejects.toThrow(TypeError); + await expect(navigator.clipboard.write(123 as never)).rejects.toThrow(TypeError); + await expect(navigator.clipboard.write([{} as ClipboardItem])).rejects.toThrow(TypeError); + + const a = new ClipboardItem({ "text/plain": "a" }); + const b = new ClipboardItem({ "text/plain": "b" }); + await expectDOMException(navigator.clipboard.write([a, b]), "NotAllowedError"); + + // An unsupported representation rejects the write, including when the + // item also carries supported ones (nothing is silently dropped). + await expectDOMException( + navigator.clipboard.write([new ClipboardItem({ "application/x-bun": "x" })]), + "NotAllowedError", + ); + await expectDOMException( + navigator.clipboard.write([new ClipboardItem({ "text/plain": "x", "application/x-bun": "y" })]), + "NotAllowedError", + ); + + // Writing an empty sequence is a no-op that must not reject. + await navigator.clipboard.write([]); + + // A ClipboardItemData that rejects propagates as the write's rejection, + // and an uncoercible settled value rejects there too. + await expect( + navigator.clipboard.write([new ClipboardItem({ "text/plain": Promise.reject(new Error("boom")) })]), + ).rejects.toThrow("boom"); + await expect( + navigator.clipboard.write([new ClipboardItem({ "text/plain": Promise.resolve(Symbol("x")) as never })]), + ).rejects.toThrow(TypeError); + }); + + // Collection reacts to each representation directly: neither a replaced + // Promise.all nor a representation's own `then` is consulted. + test("write() collects without user-replaceable promise machinery", async () => { + const representation = Promise.resolve(Symbol("x")); + representation.then = () => undefined as never; + const realAll = Promise.all; + Promise.all = () => { + throw new Error("Promise.all was called"); + }; + try { + await expect( + navigator.clipboard.write([new ClipboardItem({ "text/plain": representation as never })]), + ).rejects.toThrow(TypeError); + } finally { + Promise.all = realAll; + } + }); + + test("terminating a worker while write() runs its user code ends the worker", async () => { + using dir = tempDir("clipboard-terminate", { + "worker.js": ` + postMessage("collecting"); + navigator.clipboard.write([new ClipboardItem({ "text/plain": { toString() { for (;;); } } })]); + `, + }); + const worker = new Worker(join(String(dir), "worker.js")); + const failed = once(worker, "error").then(([error]) => Promise.reject(error)); + const closed = once(worker, "close"); + await Promise.race([once(worker, "message"), failed]); + worker.terminate(); + await Promise.race([closed, failed]); + }); + + // A coercion can start another write of its item and then throw. The write it + // was coercing for is aborted by then, so nothing observes the throw, as with + // a promise that is already settled. It must not become an uncaught error. + test("a coercion that re-enters write() and then throws is not an uncaught error", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + let inner = null, item; + // Never reaches the platform: it aborts \`inner\` while that still collects. + const never = new ClipboardItem({ "text/plain": new Promise(() => {}) }); + const representation = { + toString() { + if (inner) return "unused"; + inner = navigator.clipboard.write([item]).then(() => "resolved", e => e.name); + navigator.clipboard.write([never]); + throw new TypeError("thrown after re-entry"); + }, + }; + item = new ClipboardItem({ "text/plain": representation }); + const outer = await navigator.clipboard.write([item]).then(() => "resolved", e => e.name); + console.log(JSON.stringify({ outer, inner: await inner })); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ outer: "AbortError", inner: "AbortError" }), + stderr: "", + exitCode: 0, + }); + }); + + // The rejections a caller can act on say what was wrong. All of these are + // decided before the OS clipboard is involved, so they are the same + // everywhere except the per-item limit, which only the one-shot POSIX + // helpers have. + test("write() rejections name the problem", async () => { + const rejection = (promise: Promise) => + promise.then( + () => "resolved", + (e: DOMException) => `${e.name}: ${e.message}`, + ); + using fileDir = tempDir("clipboard-messages", {}); + const singleRepresentation = !isMacOS && !isWindows; + // Marked handled up front: where the per-item limit applies it is never collected. + const neverCollected = Promise.reject(new Error("never collected")); + neverCollected.catch(() => {}); + const outcomes = { + twoItems: await rejection( + navigator.clipboard.write([new ClipboardItem({ "text/plain": "a" }), new ClipboardItem({ "text/plain": "b" })]), + ), + unsupportedType: await rejection( + navigator.clipboard.write([new ClipboardItem({ "text/plain": "a", "application/x-bun": "b" })]), + ), + sameEssenceTwice: await rejection( + navigator.clipboard.write([new ClipboardItem({ "text/plain": "a", "text/plain;charset=utf-8": "b" })]), + ), + unreadableFile: await rejection( + navigator.clipboard.write([ + new ClipboardItem({ "text/plain": Bun.file(join(String(fileDir), "missing.txt")) }), + ]), + ), + twoRepresentations: await rejection( + navigator.clipboard.write([new ClipboardItem({ "text/plain": "a", "text/html": neverCollected })]), + ), + }; + expect(outcomes).toEqual({ + twoItems: "NotAllowedError: Writing multiple ClipboardItems is not supported.", + unsupportedType: 'NotAllowedError: The type "application/x-bun" is not supported on this platform.', + sameEssenceTwice: 'NotAllowedError: Writing two "text/plain" representations is not supported.', + // The read error is passed through once, not re-prefixed with its code. + unreadableFile: expect.stringMatching( + /^NotAllowedError: ENOENT: no such file or directory, open '.*missing\.txt'$/, + ), + // Where items can hold several representations the rejection comes from + // collecting them (the rejected representation's own reason), proving + // the per-item limit is not applied there. + twoRepresentations: singleRepresentation + ? "NotAllowedError: Writing more than one representation per item is not supported on this platform." + : "Error: never collected", + }); + }); + + // A file-backed Blob (Bun.file) has no resident bytes; the writer reads it + // in before the platform transaction instead of rejecting it. + test.skipIf(!systemClipboard)("write() reads file-backed representations in", async () => { + using fileDir = tempDir("clipboard-file-blob", { "a.txt": "from disk" }); + const write = navigator.clipboard.write([ + new ClipboardItem({ "text/plain": Bun.file(join(String(fileDir), "a.txt")) }), + ]); + if (!clipboardReachable) { + await expectDOMException(write, "NotAllowedError"); + return; + } + await write; + expect(await navigator.clipboard.readText()).toBe("from disk"); + }); + + // Spec: getType() resolves the representation's Blob ("resolve p with v"); + // a file-backed one passes through lazily, even when its declared type + // differs from the representation's key. + test("getType() passes file-backed Blobs through as lazy Blobs", async () => { + using fileDir = tempDir("clipboard-file-gettype", { "a.txt": "lazy bytes" }); + const item = new ClipboardItem({ "text/html": Bun.file(join(String(fileDir), "a.txt")) }); + const blob = await item.getType("text/html"); + expect(blob).not.toBeInstanceOf(File); + // The lazy dupe reports the requested type, like the resident re-wrap + // path, not the source file's extension-inferred one. + expect(blob.type).toBe("text/html"); + expect(await blob.text()).toBe("lazy bytes"); + }); + + // Regression: the write's data source holds only a WeakPtr back-edge to its + // item, so the ItemWriter has to own the items. Without that, an item whose + // representation never settles is collected mid-write and destroys its data + // source with the collect completion still armed: a debug assert, and a + // permanently pending promise in release. + test.skipIf(!systemClipboard)("an in-flight write keeps its item alive across GC", async () => { + // No reference to the item survives this statement; only the writer holds + // it. Resolve the representation *after* GC so the assertion is that the + // write still completes, a symptom visible in release, unlike "still + // pending", which is also what the bug looks like. + const { promise: rep, resolve } = Promise.withResolvers(); + const write = navigator.clipboard.write([new ClipboardItem({ "text/plain": rep })]); + Bun.gc(true); + Bun.gc(true); + resolve("survived"); + // Settles either way (a machine with no clipboard rejects NotAllowedError); + // what must not happen is hanging forever because the item was collected. + const outcome = await write.then( + () => "settled", + e => (e as Error).name, + ); + expect(["settled", "NotAllowedError"]).toContain(outcome); + }); + + // A write() still collecting its item is superseded by any later write. + test.skipIf(!systemClipboard)("writeText() supersedes an in-flight write()", async () => { + const { promise: rep } = Promise.withResolvers(); + const first = navigator.clipboard.write([new ClipboardItem({ "text/plain": rep })]); + const later = navigator.clipboard.writeText("later").catch(() => {}); + await expectDOMException(first, "AbortError"); + await later; + }); + + // Per spec (and Chrome), write([]) resolves without touching (or clearing) + // the clipboard. + test.skipIf(!systemClipboard)("write([]) resolves and leaves the clipboard contents alone", async () => { + if (!clipboardReachable) { + await navigator.clipboard.write([]); + return; + } + await navigator.clipboard.writeText("kept"); + await navigator.clipboard.write([]); + expect(await navigator.clipboard.readText()).toBe("kept"); + }); + + // write([]) resolves without reaching the OS, but it is still a write() call + // and must abort an in-flight write() rather than letting it land later. + test("write([]) supersedes an in-flight write()", async () => { + const { promise: rep } = Promise.withResolvers(); + const first = navigator.clipboard.write([new ClipboardItem({ "text/plain": rep })]); + await navigator.clipboard.write([]); + await expectDOMException(first, "AbortError"); + }); + + // A superseded writer retires the collect armed on its item, whose completion + // holds the writer; otherwise writer, item and the user's promise leak. + test("a superseded write releases its items and their promises", async () => { + const refs: WeakRef[] = []; + for (let i = 0; i < 300; i++) { + const stuck = new Promise(() => {}); + refs.push(new WeakRef(stuck)); + // Each iteration supersedes the previous writer, which never settles. + navigator.clipboard.write([new ClipboardItem({ "text/plain": stuck })]).catch(() => {}); + } + for (let i = 0; i < 5; i++) Bun.gc(true); + const live = refs.filter(r => r.deref() !== undefined).length; + // The most recent writer legitimately still holds its item; everything + // before it must be collectable. + expect(live).toBeLessThan(20); + }); + + // Regression: `copy` listeners run synchronously from the finishing write, so + // a listener that starts another write over the same item used to have its + // freshly-armed collect retired by the writer that was tearing down. + test.skipIf(!systemClipboard)( + "a write started from a copy listener is not cancelled by the one that fired it", + async () => { + const item = new ClipboardItem({ "text/plain": new Blob(["nested"], { type: "text/plain" }) }); + let nested: Promise | null = null; + const onCopy = () => { + if (!nested) nested = navigator.clipboard.write([item]); + }; + navigator.clipboard.addEventListener("copy", onCopy); + try { + const outer = navigator.clipboard.write([item]); + await outer.catch(() => {}); + if (!nested) { + // No copy event ⇔ no reachable clipboard: the write must have rejected. + await expectDOMException(outer, "NotAllowedError"); + return; + } + const outcome = await nested.then( + () => "settled", + (e: Error) => e.name, + ); + expect(["settled", "NotAllowedError"]).toContain(outcome); + } finally { + navigator.clipboard.removeEventListener("copy", onCopy); + } + }, + ); + + test.skipIf(!systemClipboard)( + "round-trips representations, or rejects with NotAllowedError where there is no clipboard", + async () => { + if (!clipboardReachable) { + // No reachable clipboard (e.g. headless Linux): read() and write() + // must fail with the same spec'd shape. + await expectDOMException(navigator.clipboard.read(), "NotAllowedError"); + await expectDOMException( + navigator.clipboard.write([new ClipboardItem({ "text/plain": "x" })]), + "NotAllowedError", + ); + return; + } + // A unique token makes an unrelated process racing the clipboard a + // visible mismatch instead of a false pass. Multi-representation items + // are native-only: the POSIX helpers can hold one representation. + const token = `bun clipboard read/write ${Date.now()} ${Math.random()}`; + const types: Record = { "text/plain": token }; + const multiRep = process.platform === "darwin" || process.platform === "win32"; + const withHtml = multiRep && ClipboardItem.supports("text/html"); + if (withHtml) types["text/html"] = `${token}`; + await navigator.clipboard.write([new ClipboardItem(types)]); + + const items = await navigator.clipboard.read(); + expect(items).toHaveLength(1); + expect(items[0]).toBeInstanceOf(ClipboardItem); + expect(items[0].types).toEqual(withHtml ? ["text/plain", "text/html"] : ["text/plain"]); + expect(await (await items[0].getType("text/plain")).text()).toBe(token); + expect((await items[0].getType("text/plain")).type).toBe("text/plain"); + if (withHtml) { + expect(await (await items[0].getType("text/html")).text()).toBe(`${token}`); + } + // readText() sees the text/plain representation written by write(). + expect(await navigator.clipboard.readText()).toBe(token); + + // Binary representations survive the platform round-trip byte-exact (on + // Windows that means the backend trims GlobalSize's rounding off the PNG). + await navigator.clipboard.write([new ClipboardItem({ "image/png": new Blob([PNG_1X1], { type: "image/png" }) })]); + const [imageItem] = await navigator.clipboard.read(); + expect(imageItem.types).toEqual(["image/png"]); + expect(Buffer.from(await (await imageItem.getType("image/png")).arrayBuffer())).toEqual(PNG_1X1); + }, + ); +}); + +describe("clipboard events", () => { + // Bun's projection of the spec's clipboard actions onto a runtime: writes + // that place data fire "copy", successful reads fire "paste" (both at + // `navigator.clipboard`), failures fire nothing, and "cut" never auto-fires. + test.skipIf(!systemClipboard)("copy/paste fire at navigator.clipboard on success, and only on success", async () => { + const unavailable = !clipboardReachable; + const events: string[] = []; + let lastEvent: ClipboardEvent | null = null; + const record = (e: Event) => { + events.push(e.type); + lastEvent = e as ClipboardEvent; + }; + navigator.clipboard.addEventListener("copy", record); + navigator.clipboard.addEventListener("paste", record); + navigator.clipboard.addEventListener("cut", record); + try { + const token = `clipboard-events ${Date.now()} ${Math.random()}`; + if (unavailable) { + // With no reachable clipboard every operation rejects, and a failed + // operation must not fire any event. + await expectDOMException(navigator.clipboard.writeText(token), "NotAllowedError"); + await expectDOMException(navigator.clipboard.readText(), "NotAllowedError"); + expect(events).toEqual([]); + return; + } + await navigator.clipboard.writeText(token); + expect(events).toEqual(["copy"]); + // The fired event has the spec'd shape and targets navigator.clipboard. + expect(lastEvent).toBeInstanceOf(ClipboardEvent); + expect(lastEvent!.type).toBe("copy"); + expect(lastEvent!.target).toBe(navigator.clipboard); + expect(lastEvent!.bubbles).toBe(false); + expect(lastEvent!.cancelable).toBe(false); + expect(lastEvent!.clipboardData).toBeNull(); + + expect(await navigator.clipboard.readText()).toBe(token); + expect(events).toEqual(["copy", "paste"]); + await navigator.clipboard.write([new ClipboardItem({ "text/plain": token })]); + expect(events).toEqual(["copy", "paste", "copy"]); + await navigator.clipboard.read(); + expect(events).toEqual(["copy", "paste", "copy", "paste"]); + + // Neither a rejected validation nor the empty no-op write fires. + await expectDOMException( + navigator.clipboard.write([new ClipboardItem({ "application/x-bun": "x" })]), + "NotAllowedError", + ); + await navigator.clipboard.write([]); + expect(events).toEqual(["copy", "paste", "copy", "paste"]); + } finally { + navigator.clipboard.removeEventListener("copy", record); + navigator.clipboard.removeEventListener("paste", record); + navigator.clipboard.removeEventListener("cut", record); + } + }); +}); + +describe("readText / writeText", () => { + test.skipIf(!systemClipboard)( + "round-trips text, or rejects with NotAllowedError where there is no system clipboard", + async () => { + if (!clipboardReachable) { + // No reachable clipboard here (e.g. headless Linux with no display): + // the spec'd failure is a "NotAllowedError" DOMException for both. + await expectDOMException(navigator.clipboard.readText(), "NotAllowedError"); + await expectDOMException(navigator.clipboard.writeText("x"), "NotAllowedError"); + return; + } + // A unique token makes an unrelated process racing the system clipboard + // a clear mismatch instead of a false pass. + const token = `bun-clipboard-test ${Date.now()} ${Math.random()}`; + expect(await navigator.clipboard.writeText(token)).toBeUndefined(); + expect(await navigator.clipboard.readText()).toBe(token); + + // Non-ASCII text must survive the platform round-trip byte-for-byte. + const unicode = "héllo 🌍 — ünïcödé ✂️📋"; + await navigator.clipboard.writeText(unicode); + expect(await navigator.clipboard.readText()).toBe(unicode); + + // Spec note on writeText: Windows converts bare LF to CRLF for + // CF_UNICODETEXT; other platforms write the text byte-for-byte. + await navigator.clipboard.writeText("line1\nline2\r\nline3"); + expect(await navigator.clipboard.readText()).toBe( + process.platform === "win32" ? "line1\r\nline2\r\nline3" : "line1\nline2\r\nline3", + ); + + // WebIDL DOMString conversion: null becomes the string "null". + await navigator.clipboard.writeText(null as unknown as string); + expect(await navigator.clipboard.readText()).toBe("null"); + + // Writing "" is legal, and readText() of an empty clipboard resolves "". + await navigator.clipboard.writeText(""); + expect(await navigator.clipboard.readText()).toBe(""); + // An empty text/plain representation is present, not absent: `read()` + // resolves `[ClipboardItem]` with a 0-byte text/plain Blob, like browsers. + const emptyItems = await navigator.clipboard.read(); + expect(emptyItems).toHaveLength(1); + expect(emptyItems[0].types).toContain("text/plain"); + expect(await (await emptyItems[0].getType("text/plain")).text()).toBe(""); + }, + ); + + // A string has a length limit, and a clipboard can hold more text than that. + // The child lowers the limit, so the text is 1 MiB and not 2 GiB. + test.skipIf(!machineHasClipboard)("readText() rejects text that is too large for a string", async () => { + expect(clipboardReachable).toBe(true); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { setSyntheticAllocationLimitForTesting } = require("bun:internal-for-testing"); + let pastes = 0; + navigator.clipboard.addEventListener("paste", () => pastes++); + await navigator.clipboard.writeText(Buffer.alloc(1024 * 1024 + 1, "a").toString()); + setSyntheticAllocationLimitForTesting(1024 * 1024); + const tooLarge = await navigator.clipboard.readText().then(text => text.length, e => e.name + ": " + e.message); + const pastesForTooLarge = pastes; + await navigator.clipboard.writeText("fits"); + console.log(JSON.stringify({ tooLarge, pastesForTooLarge, fits: await navigator.clipboard.readText() })); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) throw new Error(`child exited with ${exitCode}\n${stderr}`); + expect(JSON.parse(stdout)).toEqual({ tooLarge: TEXT_TOO_LARGE, pastesForTooLarge: 0, fits: "fits" }); + }); +}); + +// The POSIX backend has no clipboard API to call: it runs `wl-paste`/`wl-copy`, +// `xclip` or `xsel` and has to make sense of whatever they do. CI has no +// display, so stand-ins on PATH play the helpers, which also makes every +// failure mode below reproducible. Each test is its own child process with +// its own directory, so they run concurrently. +type Helper = "xclip" | "xsel" | "wl-paste" | "wl-copy"; +const HELPERS: Helper[] = ["xclip", "xsel", "wl-paste", "wl-copy"]; + +const NO_DISPLAY = + "NotAllowedError: The clipboard requires a Wayland or X11 display, but neither $WAYLAND_DISPLAY nor $DISPLAY is set."; +const NO_HELPER = "NotAllowedError: No clipboard helper was found. Install `wl-clipboard` (Wayland) or `xclip` (X11)."; +const HELPER_FAILED = "NotAllowedError: The clipboard helper program failed to access the clipboard."; +const TEXT_TOO_LARGE = "NotAllowedError: The text on the clipboard is too large to read as a string."; + +// Whether a process is still there. One that was ended and that nobody has +// collected yet (a zombie) is not. +function isRunning(pid: string) { + try { + const stat = readFileSync("/proc/" + pid + "/stat", "utf8"); + return stat[stat.lastIndexOf(")") + 2] !== "Z"; + } catch (e: any) { + if (e.code !== "ENOENT" && e.code !== "ESRCH") throw e; + return false; + } +} + +// A signal ends a process at its own pace, so this waits for that. +async function hasEnded(pid: string) { + for (let tries = 0; tries < 200 && isRunning(pid); tries++) await Bun.sleep(10); + return !isRunning(pid); +} + +// Available to every child script: settle a promise into something JSON can +// carry, read what a stand-in recorded, and print the one line the test reads. +// `limitHelperRuns` replaces the 10 seconds a helper run may take. `running` +// and `ended` are the two functions above, for a pid that a stand-in recorded. +// On a machine under heavy load the time limit can end a stand-in before it +// records the pid. Then nothing of it is left to look at. `started` waits for +// a file that a stand-in writes first, and fails when none comes. +const CHILD_PRELUDE = ` + const { readFileSync, readdirSync } = require("node:fs"); + const CLIP_DIR = process.env.CLIP_DIR; + const settle = (promise, map = value => value) => + promise.then(async value => ({ ok: await map(value) }), e => ({ error: e.name + ": " + e.message })); + const types = items => items.map(item => [...item.types]); + const received = name => readFileSync(CLIP_DIR + "/" + name, "utf8"); + const leftovers = () => readdirSync(process.env.TMPDIR); + const print = value => console.log(JSON.stringify(value)); + const limitHelperRuns = ms => require("bun:internal-for-testing").setClipboardHelperTimeoutForTesting(ms); + ${isRunning} + ${hasEnded} + const started = async name => { + for (let tries = 0; tries < 600; tries++) { + if (require("node:fs").existsSync(CLIP_DIR + "/" + name)) return; + await Bun.sleep(5); + } + throw new Error("the stand-in never wrote " + name); + }; + const recordedPid = name => { + try { + return received(name).trim(); + } catch (e) { + if (e.code !== "ENOENT") throw e; + return ""; + } + }; + const running = name => recordedPid(name) !== "" && isRunning(recordedPid(name)); + const ended = async name => recordedPid(name) === "" || (await hasEnded(recordedPid(name))); +`; + +// A pidfd tells the wait that a helper exited, and a memfd holds its output. +// Where the kernel has neither (FreeBSD), the wait looks at the helper at +// intervals and the output comes through a socket pair. +const NO_PIDFD_NO_MEMFD = { BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1", BUN_FEATURE_FLAG_DISABLE_MEMFD: "1" }; + +// Runs `script` in a child whose PATH starts with a directory of stand-ins for +// the four helpers. A stand-in appends " " to a log and then runs +// its sh `body`; a helper given no body exits 127, which is what sh reports +// for a program that is not installed. The child sees the directory as +// $CLIP_DIR, and $TMPDIR (where writes stage their payload) is a subdirectory +// whose name needs quoting. Returns the child's JSON line and the log. +async function runWithHelpers( + bodies: Partial>, + script: string, + env: Record = {}, + extraFiles: Record = {}, +) { + const files: Record> = { + ...extraFiles, + "main.js": CHILD_PRELUDE + script, + "tmp 'dir'": {}, + }; + for (const helper of HELPERS) { + files[helper] = `#!/bin/sh\nprintf '%s\\n' "\${0##*/} $*" >> "$CLIP_DIR/log"\n${bodies[helper] ?? "exit 127"}\n`; + } + using dir = tempDir("clipboard-helpers", files); + for (const helper of HELPERS) chmodSync(join(String(dir), helper), 0o755); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: String(dir), + env: { + ...bunEnv, + PATH: `${dir}:${bunEnv.PATH ?? process.env.PATH}`, + DISPLAY: ":0", + WAYLAND_DISPLAY: undefined, + CLIP_DIR: String(dir), + TMPDIR: join(String(dir), "tmp 'dir'"), + // The CI runner exports BUN_TMPDIR, which Bun prefers over TMPDIR. + BUN_TMPDIR: join(String(dir), "tmp 'dir'"), + ...env, + }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) throw new Error(`child exited with ${exitCode}\n${stderr}\n${stdout}`); + const logPath = join(String(dir), "log"); + const log = existsSync(logPath) ? readFileSync(logPath, "utf8").trimEnd().split("\n") : []; + return { result: JSON.parse(stdout), log }; +} + +describe.concurrent.skipIf(!isLinux)("POSIX helper backend", () => { + test("round-trips through a helper, firing copy/paste; an empty text/plain is present", async () => { + const { result, log } = await runWithHelpers( + { + xclip: `case "$*" in *TARGETS*) printf 'TARGETS\\nUTF8_STRING\\n' ;; *-out*) cat "$CLIP_DIR/state" ;; *) cat > "$CLIP_DIR/state" ;; esac`, + }, + ` + const events = []; + navigator.clipboard.addEventListener("copy", e => events.push(e.type)); + navigator.clipboard.addEventListener("paste", e => events.push(e.type)); + const token = "helper path \\u2702 " + Date.now(); + await navigator.clipboard.writeText(token); + const back = await navigator.clipboard.readText(); + await navigator.clipboard.writeText(""); + print({ roundTripped: back === token, emptyTypes: types(await navigator.clipboard.read()), events }); + `, + ); + expect({ result, log }).toEqual({ + result: { roundTripped: true, emptyTypes: [["text/plain"]], events: ["copy", "paste", "copy", "paste"] }, + log: [ + "xclip -selection clipboard -in", + "xclip -selection clipboard -out", + "xclip -selection clipboard -in", + // read() asks what is offered and reads only that. + "xclip -selection clipboard -t TARGETS -out", + "xclip -selection clipboard -out", + ], + }); + }); + + test("without a display nothing is spawned and every method says which variable is missing", async () => { + const { result, log } = await runWithHelpers( + { xclip: "printf must-not-run", xsel: "printf must-not-run" }, + ` + print({ + readText: await settle(navigator.clipboard.readText()), + read: await settle(navigator.clipboard.read()), + writeText: await settle(navigator.clipboard.writeText("x")), + write: await settle(navigator.clipboard.write([new ClipboardItem({ "text/plain": "x" })])), + }); + `, + // An empty $DISPLAY counts as unset. + { DISPLAY: "" }, + ); + expect({ result, log }).toEqual({ + result: { + readText: { error: NO_DISPLAY }, + read: { error: NO_DISPLAY }, + writeText: { error: NO_DISPLAY }, + write: { error: NO_DISPLAY }, + }, + log: [], + }); + }); + + // process.env is the environment a script's children get, not the launching + // process's: a display set at runtime reaches the helper, and a helper's PATH + // is the script's. + test("the display and the helper's environment come from process.env", async () => { + const { result } = await runWithHelpers( + { xclip: `printf 'display=%s' "$DISPLAY"` }, + ` + process.env.DISPLAY = ":7"; + print({ readText: await settle(navigator.clipboard.readText()) }); + `, + { DISPLAY: undefined }, + ); + expect(result).toEqual({ readText: { ok: "display=:7" } }); + }); + + // A thread creates its process.env on the first read. For this worker that + // read is the clipboard operation itself, and creating the object can throw. + // A build with exception checks aborts when a caller does not check for that. + test("a worker that never read process.env still hands it to the helper", async () => { + const { result } = await runWithHelpers( + { xclip: `printf 'display=%s' "$DISPLAY"` }, + ` + const worker = new Worker(new URL("./worker.js", import.meta.url)); + print(await new Promise(resolve => worker.addEventListener("message", e => resolve(e.data), { once: true }))); + `, + { BUN_JSC_validateExceptionChecks: "1" }, + { + "worker.js": `navigator.clipboard.readText().then(text => postMessage({ ok: text }), e => postMessage({ error: e.name + ": " + e.message }));`, + }, + ); + expect(result).toEqual({ ok: "display=:0" }); + }); + + test("with a display but nothing installed, the rejection says what to install", async () => { + const { result, log } = await runWithHelpers( + {}, + ` + print({ + readText: await settle(navigator.clipboard.readText()), + read: await settle(navigator.clipboard.read()), + writeText: await settle(navigator.clipboard.writeText("x")), + write: await settle(navigator.clipboard.write([new ClipboardItem({ "text/html": "x" })])), + }); + `, + // Both displays, so every Wayland and X11 candidate is tried; the + // stand-ins report "not installed" the way sh does for a missing program. + { WAYLAND_DISPLAY: "wayland-0" }, + ); + expect({ result, log }).toEqual({ + result: { + readText: { error: NO_HELPER }, + read: { error: NO_HELPER }, + writeText: { error: NO_HELPER }, + write: { error: NO_HELPER }, + }, + log: [ + "wl-paste --no-newline --type text", + "xclip -selection clipboard -out", + "xsel --clipboard --output", + "wl-paste --list-types", + "xclip -selection clipboard -t TARGETS -out", + "xsel --clipboard --output", + "wl-copy --type text/plain;charset=utf-8", + "xclip -selection clipboard -in", + "wl-copy --type text/html", + "xclip -selection clipboard -t text/html -in", + ], + }); + }); + + // No helper on PATH: nothing is spawned, and the answer is "not installed". + // An empty PATH does not mean the working directory, where the stand-ins are. + test.each(["/nonexistent/clipboard-helpers", ""])("PATH=%j has no helper to run", async PATH => { + const { result, log } = await runWithHelpers( + { xclip: "printf must-not-run" }, + `print({ readText: await settle(navigator.clipboard.readText()), writeText: await settle(navigator.clipboard.writeText("x")) });`, + { PATH }, + ); + expect({ result, log }).toEqual({ + result: { readText: { error: NO_HELPER }, writeText: { error: NO_HELPER } }, + log: [], + }); + }); + + // Firefox puts text/html on the X11 clipboard as UTF-16 with a byte order mark. + test("read() decodes UTF-16 text/html to UTF-8", async () => { + const { result } = await runWithHelpers( + { + xclip: `case "$*" in *TARGETS*) printf 'TARGETS\\ntext/html\\n' ;; *text/html*) printf '\\377\\376<\\000b\\000>\\000h\\000i\\000<\\000/\\000b\\000>\\000' ;; esac`, + }, + ` + const [item] = await navigator.clipboard.read(); + print({ types: [...item.types], html: await (await item.getType("text/html")).text() }); + `, + ); + expect(result).toEqual({ types: ["text/html"], html: "hi" }); + }); + + // A string has a length limit, and a helper can print more text than that. + // The child lowers the limit to 1 MiB, so no helper has to print 2 GiB. The + // limit counts UTF-16 units, which "two-byte" has half as many of as bytes. + test("readText() rejects text that is too large for a string", async () => { + const { result } = await runWithHelpers( + { xclip: `cat "$CLIP_DIR/$(cat "$CLIP_DIR/selection")"` }, + ` + const { writeFileSync } = require("node:fs"); + require("bun:internal-for-testing").setSyntheticAllocationLimitForTesting(1024 * 1024); + let pastes = 0; + navigator.clipboard.addEventListener("paste", () => pastes++); + const result = {}; + for (const selection of ["fits", "too-large", "two-byte"]) { + pastes = 0; + writeFileSync(CLIP_DIR + "/selection", selection); + result[selection] = { ...(await settle(navigator.clipboard.readText(), text => text.length)), pastes }; + } + print(result); + `, + {}, + { + "fits": Buffer.alloc(1024 * 1024, "a").toString(), + "too-large": Buffer.alloc(1024 * 1024 + 1, "a").toString(), + "two-byte": Buffer.alloc(1024 * 1024 + 2, "é").toString(), + }, + ); + expect(result).toEqual({ + "fits": { ok: 1024 * 1024, pastes: 1 }, + "too-large": { error: TEXT_TOO_LARGE, pastes: 0 }, + "two-byte": { ok: 512 * 1024 + 1, pastes: 1 }, + }); + }); + + test("Wayland helpers are preferred, X11 ones are the fallback, and read() is best-effort per type", async () => { + const { result, log } = await runWithHelpers( + { + // wl-paste is "not installed" (no body), so reads fall through to xclip, + // which offers all three types but has nothing for html (exit 0, no + // output) and crashes on png. wl-copy is installed, so writes never + // reach xclip. + xclip: `case "$*" in *TARGETS*) printf 'TARGETS\\nUTF8_STRING\\ntext/html\\nimage/png\\n' ;; *image/png*) kill -KILL $$ ;; *text/html*) exit 0 ;; *) printf 'from xclip' ;; esac`, + xsel: "printf 'from xsel'", + "wl-copy": `cat > "$CLIP_DIR/wl-copy-received"`, + }, + ` + print({ + readText: await settle(navigator.clipboard.readText()), + read: await settle(navigator.clipboard.read(), types), + writeText: await settle(navigator.clipboard.writeText("hello"), () => received("wl-copy-received")), + writeHtml: await settle( + navigator.clipboard.write([new ClipboardItem({ "text/html": "hi" })]), + () => received("wl-copy-received"), + ), + }); + `, + { WAYLAND_DISPLAY: "wayland-0" }, + ); + expect({ result, log }).toEqual({ + result: { + readText: { ok: "from xclip" }, + // xclip offers png but crashes delivering it, so xsel answers instead. + read: { ok: [["text/plain"]] }, + writeText: { ok: "hello" }, + writeHtml: { ok: "hi" }, + }, + log: [ + "wl-paste --no-newline --type text", + "xclip -selection clipboard -out", + "wl-paste --list-types", + "xclip -selection clipboard -t TARGETS -out", + "xclip -selection clipboard -out", + "xclip -selection clipboard -t text/html -out", + "xclip -selection clipboard -t image/png -out", + "xsel --clipboard --output", + "wl-copy --type text/plain;charset=utf-8", + "wl-copy --type text/html", + ], + }); + }); + + // A helper that exits non-zero after reaching the display is saying nothing + // is copied, and the first helper that answers is the one that counts. One + // that could not reach the display proves nothing (a stale $DISPLAY). + test.each([ + { + reached: true, + stderr: "Error: target STRING not available", + readText: { ok: "" }, + read: { ok: [] }, + reads: ["xclip -selection clipboard -out", "xclip -selection clipboard -t TARGETS -out"], + }, + { + reached: false, + stderr: "Error: Can't open display: :0", + readText: { error: HELPER_FAILED }, + read: { error: HELPER_FAILED }, + reads: [ + "xclip -selection clipboard -out", + "xsel --clipboard --output", + "xclip -selection clipboard -t TARGETS -out", + "xsel --clipboard --output", + ], + }, + ])("a helper exiting non-zero (display reached: $reached)", async ({ stderr, readText, read, reads }) => { + const body = `echo "${stderr}" >&2; exit 1`; + const { result, log } = await runWithHelpers( + { xclip: body, xsel: body }, + ` + print({ + readText: await settle(navigator.clipboard.readText()), + read: await settle(navigator.clipboard.read(), types), + writeText: await settle(navigator.clipboard.writeText("x")), + }); + `, + ); + expect({ result, log }).toEqual({ + result: { readText, read, writeText: { error: HELPER_FAILED } }, + log: [...reads, "xclip -selection clipboard -in"], + }); + }); + + test("a helper that dies is skipped in favor of the next candidate", async () => { + const { result, log } = await runWithHelpers( + { + "wl-copy": "kill -TERM $$", + xclip: `case "$*" in *-out*) kill -TERM $$ ;; *) cat > "$CLIP_DIR/xclip-received" ;; esac`, + xsel: "printf 'from xsel'", + }, + ` + print({ + readText: await settle(navigator.clipboard.readText()), + writeText: await settle(navigator.clipboard.writeText("via xclip"), () => received("xclip-received")), + }); + `, + { WAYLAND_DISPLAY: "wayland-0" }, + ); + expect({ result, log }).toEqual({ + result: { readText: { ok: "from xsel" }, writeText: { ok: "via xclip" } }, + log: [ + "wl-paste --no-newline --type text", + "xclip -selection clipboard -out", + "xsel --clipboard --output", + "wl-copy --type text/plain;charset=utf-8", + "xclip -selection clipboard -in", + ], + }); + }); + + test("when every candidate dies the failure is reported", async () => { + const { result } = await runWithHelpers( + { xclip: "kill -KILL $$", xsel: "kill -KILL $$" }, + ` + print({ + readText: await settle(navigator.clipboard.readText()), + read: await settle(navigator.clipboard.read()), + writeText: await settle(navigator.clipboard.writeText("x")), + writeHtml: await settle(navigator.clipboard.write([new ClipboardItem({ "text/html": "x" })])), + }); + `, + ); + expect(result).toEqual({ + readText: { error: HELPER_FAILED }, + read: { error: HELPER_FAILED }, + writeText: { error: HELPER_FAILED }, + writeHtml: { error: HELPER_FAILED }, + }); + }); + + // A hung selection owner: the helper records its pid and never returns. It + // also ignores SIGTERM, which is a request and not an order. The shell that + // has the trap is the one that waits, so the stand-in does not `exec` its + // sleep. The time limit needs no program from PATH (no `sh`, no `sleep`), + // which has only the stand-ins here. With a Wayland display alone, each + // operation has one candidate helper. + const hangs = [ + `trap '' TERM`, + `echo $$ > "$CLIP_DIR/helper-pid"`, + `n=0; while [ $n -lt 300 ]; do /bin/sleep 0.1; n=$((n + 1)); done`, + ].join("\n"); + const WAYLAND_ONLY = { DISPLAY: "", WAYLAND_DISPLAY: "wayland-0" }; + test.each([ + { operation: `readText()`, kernel: "a pidfd and a memfd", env: {} }, + { operation: `writeText("x")`, kernel: "a pidfd and a memfd", env: {} }, + { operation: `readText()`, kernel: "no pidfd and no memfd", env: NO_PIDFD_NO_MEMFD }, + ])( + "a helper that hangs is ended at the time limit and the operation fails: $operation with $kernel", + async ({ operation, env }) => { + const { result } = await runWithHelpers( + { "wl-paste": hangs, "wl-copy": hangs }, + ` + process.env.PATH = CLIP_DIR; + limitHelperRuns(500); + const settled = await settle(navigator.clipboard.${operation}); + print({ settled, helperStillRunning: running("helper-pid") }); + `, + { ...WAYLAND_ONLY, ...env }, + ); + expect(result).toEqual({ settled: { error: HELPER_FAILED }, helperStillRunning: false }); + }, + ); + + // The time limit is for the helper and for everything it started. + test("a helper that waits for its own child is ended together with the child", async () => { + const { result } = await runWithHelpers( + { "wl-paste": `sleep 300 &\necho $! > "$CLIP_DIR/child-pid"\nwait` }, + ` + limitHelperRuns(500); + const readText = await settle(navigator.clipboard.readText()); + print({ readText, childEnded: await ended("child-pid") }); + `, + WAYLAND_ONLY, + ); + expect(result).toEqual({ readText: { error: HELPER_FAILED }, childEnded: true }); + }); + + // A helper's output is captured in a memfd where there is one, and through a + // socket pair where there is not. Something the helper started can hold + // either open. The answer is there when the helper exits. + test.each([ + { capture: "a memfd", env: {} }, + { capture: "a socket pair", env: { BUN_FEATURE_FLAG_DISABLE_MEMFD: "1" } }, + { capture: "a socket pair and no pidfd", env: NO_PIDFD_NO_MEMFD }, + ])("a helper that exits answers while something it started holds its output open: $capture", async ({ env }) => { + const { result } = await runWithHelpers( + { xclip: `sleep 300 &\necho $! > "$CLIP_DIR/child-pid"\nprintf 'from the helper'` }, + ` + const readText = await settle(navigator.clipboard.readText()); + const childStillRunning = running("child-pid"); + if (childStillRunning) process.kill(Number(received("child-pid")), "SIGKILL"); + print({ readText, childStillRunning }); + `, + env, + ); + expect(result).toEqual({ readText: { ok: "from the helper" }, childStillRunning: true }); + }); + + // The kernel cannot start a helper whose "#!" program is missing, is not a + // program, or is under something that is not a directory. Such a helper + // counts as not installed, so the next candidate answers. + test("a helper that the kernel cannot start is skipped in favor of the next candidate", async () => { + const interpreters = ["/nonexistent/interpreter", "/etc/passwd", "/etc/passwd/interpreter"]; + const { result, log } = await runWithHelpers( + { xsel: "printf 'from xsel'" }, + ` + const { writeFileSync } = require("node:fs"); + const result = {}; + for (const interpreter of ${JSON.stringify(interpreters)}) { + writeFileSync(CLIP_DIR + "/xclip", "#!" + interpreter + "\\n"); + result[interpreter] = { + readText: await settle(navigator.clipboard.readText()), + writeText: await settle(navigator.clipboard.writeText("x")), + }; + } + print(result); + `, + ); + const skipped = { readText: { ok: "from xsel" }, writeText: { error: NO_HELPER } }; + expect({ result, log }).toEqual({ + result: Object.fromEntries(interpreters.map(interpreter => [interpreter, skipped])), + log: interpreters.map(() => "xsel --clipboard --output"), + }); + }); + + // The time limit lives in the process that waits for the helper. When that + // process is killed, the kernel ends the helper of a read. + test("the helper of a read is ended when the process is killed", async () => { + using dir = tempDir("clipboard-orphan", { + "xclip": `#!/bin/sh\necho $$ > "$CLIP_DIR/helper-pid"\nexec sleep 300\n`, + "main.js": `await navigator.clipboard.readText();`, + }); + chmodSync(join(String(dir), "xclip"), 0o755); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: String(dir), + env: { + ...bunEnv, + PATH: `${dir}:${bunEnv.PATH ?? process.env.PATH}`, + DISPLAY: ":0", + WAYLAND_DISPLAY: undefined, + CLIP_DIR: String(dir), + }, + stdout: "ignore", + stderr: "ignore", + }); + const pidFile = join(String(dir), "helper-pid"); + let helper = ""; + for (let tries = 0; helper === ""; tries++) { + if (tries === 600) throw new Error("the stand-in never recorded its pid"); + await Bun.sleep(5); + if (existsSync(pidFile)) helper = readFileSync(pidFile, "utf8").trim(); + } + proc.kill("SIGKILL"); + await proc.exited; + const helperEnded = await hasEnded(helper); + if (!helperEnded) process.kill(Number(helper), "SIGKILL"); + expect(helperEnded).toBe(true); + }); + + // xclip and wl-copy leave a daemon behind that serves the data. The time + // limit is for the run of the helper, so the daemon stays. + test("the daemon that a write helper leaves behind is left alone", async () => { + const { result } = await runWithHelpers( + { xclip: `cat > "$CLIP_DIR/received"\nsleep 30 > /dev/null 2>&1 &\necho $! > "$CLIP_DIR/daemon-pid"` }, + ` + const writeText = await settle(navigator.clipboard.writeText("for the daemon"), () => received("received")); + const daemonStillRunning = running("daemon-pid"); + if (daemonStillRunning) process.kill(Number(received("daemon-pid")), "SIGKILL"); + print({ writeText, daemonStillRunning }); + `, + ); + expect(result).toEqual({ writeText: { ok: "for the daemon" }, daemonStillRunning: true }); + }); + + // The payload reaches the helper's stdin through a descriptor with no name + // anyone can open: a memfd, or a private temp file unlinked beforehand. + test.each([ + { staging: "memfd", env: {}, path: /^\/memfd:bun-clipboard/, mode: expect.any(String) }, + { + staging: "temp file", + env: { BUN_FEATURE_FLAG_DISABLE_MEMFD: "1" }, + path: /\/tmp 'dir'\/[^/]*bun-clipboard \(deleted\)$/, + mode: "600", + }, + ])("writes hand the payload over as an unnamed $staging", async ({ env, path, mode }) => { + const { result, log } = await runWithHelpers( + { + xclip: [ + `readlink /proc/self/fd/0 > "$CLIP_DIR/staged-path"`, + `stat -L -c %a /proc/self/fd/0 > "$CLIP_DIR/staged-mode"`, + `case "$*" in *image/png*) cat > "$CLIP_DIR/received-png" ;; *) cat > "$CLIP_DIR/received-text" ;; esac`, + ].join("\n"), + }, + ` + const text = "it's \\"quoted\\"\\n\\\\ back\\tslash \\u00e9"; + await navigator.clipboard.writeText(text); + const textOk = received("received-text") === text; + const png = Buffer.from(${JSON.stringify(PNG_1X1.toString("base64"))}, "base64"); + await navigator.clipboard.write([new ClipboardItem({ "image/png": new Blob([png], { type: "image/png" }) })]); + print({ + textOk, + pngOk: readFileSync(CLIP_DIR + "/received-png").equals(png), + stagedPath: received("staged-path").trim(), + stagedMode: received("staged-mode").trim(), + leftovers: leftovers(), + }); + `, + env, + ); + expect({ result, log }).toEqual({ + result: { textOk: true, pngOk: true, stagedPath: expect.stringMatching(path), stagedMode: mode, leftovers: [] }, + log: ["xclip -selection clipboard -in", "xclip -selection clipboard -t image/png -in"], + }); + }); + + // Regression: a VM torn down with an op in flight. The worker's teardown + // waits for the helper (released here right after terminate()), then drops + // the job's completion unrun, which has to release the request's promise on + // the worker's own thread. Any fault on that path aborts the child (debug + // assertions, ASAN) instead of exiting 0; the parent's own write afterwards + // shows the backend is still usable. + test("terminating a worker with a write in flight releases the op cleanly", async () => { + const { result, log } = await runWithHelpers( + { + xclip: [ + `: > "$CLIP_DIR/helper-started"`, + `until [ -e "$CLIP_DIR/release" ]; do sleep 0.02; done`, + `cat > /dev/null`, + ].join("\n"), + }, + ` + const { writeFileSync } = require("node:fs"); + const worker = new Worker(new URL("./worker.js", import.meta.url)); + const closed = new Promise(resolve => worker.addEventListener("close", resolve, { once: true })); + // The helper is provably blocked on the worker's behalf before terminate(). + await started("helper-started"); + worker.terminate(); + writeFileSync(CLIP_DIR + "/release", ""); + await closed; + print({ after: await settle(navigator.clipboard.writeText("after")) }); + `, + {}, + { "worker.js": `navigator.clipboard.writeText("from the worker").catch(() => {});` }, + ); + expect({ result, log }).toEqual({ + result: { after: {} }, + log: ["xclip -selection clipboard -in", "xclip -selection clipboard -in"], + }); + }); + + // A read tries one helper after another. A worker that is stopping takes no + // answer, so the helper that was running when it was terminated is the last. + test("a worker that is terminated during a read starts no further helper", async () => { + const { result, log } = await runWithHelpers( + { + xclip: [ + `: > "$CLIP_DIR/helper-started"`, + `until [ -e "$CLIP_DIR/release" ]; do sleep 0.02; done`, + `exit 1`, + ].join("\n"), + xsel: "printf 'from xsel'", + }, + ` + const { writeFileSync } = require("node:fs"); + const worker = new Worker(new URL("./worker.js", import.meta.url)); + const closed = new Promise(resolve => worker.addEventListener("close", resolve, { once: true })); + await started("helper-started"); + worker.terminate(); + writeFileSync(CLIP_DIR + "/release", ""); + await closed; + print({ closed: true }); + `, + {}, + { "worker.js": `navigator.clipboard.readText().catch(() => {});` }, + ); + expect({ result, log }).toEqual({ result: { closed: true }, log: ["xclip -selection clipboard -out"] }); + }); + + // A write is for other programs, so it is still of use when its worker is + // gone: the next candidate runs. + test("a worker that is terminated during a write still tries the next helper", async () => { + const { result, log } = await runWithHelpers( + { + "wl-copy": [ + `: > "$CLIP_DIR/helper-started"`, + `until [ -e "$CLIP_DIR/release" ]; do sleep 0.02; done`, + `exit 1`, + ].join("\n"), + xclip: `cat > "$CLIP_DIR/received"`, + }, + ` + const { writeFileSync } = require("node:fs"); + const worker = new Worker(new URL("./worker.js", import.meta.url)); + const closed = new Promise(resolve => worker.addEventListener("close", resolve, { once: true })); + await started("helper-started"); + worker.terminate(); + writeFileSync(CLIP_DIR + "/release", ""); + await closed; + print({ received: received("received") }); + `, + { WAYLAND_DISPLAY: "wayland-0" }, + { "worker.js": `navigator.clipboard.writeText("from the worker").catch(() => {});` }, + ); + expect({ result, log }).toEqual({ + result: { received: "from the worker" }, + log: ["wl-copy --type text/plain;charset=utf-8", "xclip -selection clipboard -in"], + }); + }); +}); + +// The in-process backends with operations arriving from several pool threads +// at once (how they run now that nothing queues them), plus the other user of +// the same OS clipboard in this process, Bun.Image.fromClipboard(), which reads +// it synchronously on the JS thread. Regression for both platforms: without +// one-transaction-at-a-time locking, Windows died of STATUS_HEAP_CORRUPTION and +// macOS segfaulted inside AppKit within a few rounds of this. A child process +// keeps a crash from taking the runner down; every value read must be one some +// write actually produced. +describe.skipIf(!machineHasClipboard)("concurrent operations", () => { + test("reads, writes and Bun.Image.fromClipboard() racing each other all settle with whole values", async () => { + expect(clipboardReachable).toBe(true); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const png = new Blob([Buffer.from(${JSON.stringify(PNG_1X1.toString("base64"))}, "base64")], { type: "image/png" }); + await navigator.clipboard.write([new ClipboardItem({ "text/plain": "initial", "text/html": "initial", "image/png": png })]); + const texts = new Set(); + const shapes = new Set(); + const failures = new Set(); + const images = new Set(); + const settle = promise => promise.catch(e => failures.add(e.name + ": " + e.message)); + const imageNow = () => { + try { + images.add(Bun.Image.fromClipboard() === null ? "none" : "image"); + } catch (e) { + failures.add("fromClipboard: " + e.message); + } + }; + const initialImage = [Bun.Image.hasClipboardImage() ? "image" : "none"]; + for (let round = 0; round < 10; round++) { + const ops = []; + for (let i = 0; i < 4; i++) { + ops.push(settle(navigator.clipboard.writeText("w" + round + "-" + i))); + ops.push(settle(navigator.clipboard.readText().then(text => texts.add(text)))); + ops.push(settle(navigator.clipboard.read().then(items => shapes.add(items.map(item => [...item.types].join("+")).join(","))))); + imageNow(); + } + await Promise.all(ops); + } + console.log(JSON.stringify({ initialImage, images: [...images].sort(), texts: [...texts], shapes: [...shapes], failures: [...failures] })); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) throw new Error(`child exited with ${exitCode}\n${stderr}`); + const { initialImage, images, texts, shapes, failures } = JSON.parse(stdout) as Record; + const wholeText = /^(initial|w\d+-\d+)$/; + const wholeItem = /^text\/plain(\+text\/html\+image\/png)?$/; + expect({ + initialImage, + // "none" once the writeText()s have replaced the initial item. + sawNoImage: images.includes("none"), + tornTexts: texts.filter(text => !wholeText.test(text)), + tornItems: shapes.filter(shape => !wholeItem.test(shape)), + sawText: texts.length > 0, + sawItems: shapes.length > 0, + failures, + }).toEqual({ + initialImage: ["image"], + sawNoImage: true, + tornTexts: [], + tornItems: [], + sawText: true, + sawItems: true, + failures: [], + }); + }); +}); + +// The NSPasteboard backend against the tools every macOS user has: what +// pbcopy puts on the pasteboard is what readText() returns, and pbpaste sees +// what writeText() put there, so the data is in the public plain-text type +// and not something only Bun can read. +describe.skipIf(!machineHasClipboard || !isMacOS)("macOS pasteboard interop", () => { + test("pbcopy feeds readText(), and pbpaste sees writeText()", async () => { + expect(clipboardReachable).toBe(true); + const fromPbcopy = `from pbcopy ${Date.now()}`; + await using pbcopy = Bun.spawn({ cmd: ["pbcopy"], stdin: "pipe", stderr: "pipe" }); + pbcopy.stdin.write(fromPbcopy); + await pbcopy.stdin.end(); + // Drained so a chatty tool cannot block; the text is in the diff, not asserted. + expect(await Promise.all([pbcopy.stderr.text(), pbcopy.exited])).toEqual([expect.any(String), 0]); + expect(await navigator.clipboard.readText()).toBe(fromPbcopy); + expect((await navigator.clipboard.read()).map(item => [...item.types])).toEqual([["text/plain"]]); + + const fromBun = `from bun ${Date.now()}`; + await navigator.clipboard.writeText(fromBun); + await using pbpaste = Bun.spawn({ cmd: ["pbpaste"], stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + pbpaste.stdout.text(), + pbpaste.stderr.text(), + pbpaste.exited, + ]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: fromBun, stderr: expect.any(String), exitCode: 0 }); + }); +}); + +// The Win32 backend has to interoperate with every other producer and +// consumer of CF_UNICODETEXT, "HTML Format" (CF_HTML) and "PNG". These tests +// stand in for them by driving the raw clipboard through bun:ffi: placing +// payloads the way other programs lay them out, and inspecting exactly what +// Bun places. Everything here mutates the real clipboard, so nothing is +// concurrent. +const CF_TEXT = 1; +const CF_UNICODETEXT = 13; +const CF_DIB = 8; +const CF_DIBV5 = 17; +// GMEM_MOVEABLE | GMEM_ZEROINIT, so any allocation past the payload reads as NUL. +const GHND = 0x0042; +const START_MARK = ""; +const END_MARK = ""; + +interface RawEntry { + format: number; + bytes: Uint8Array; + /** Allocation size when it should exceed the payload; defaults to the exact length. */ + size?: number; +} + +interface Win32Clipboard { + /** RegisterClipboardFormatW: the id the backend gets for the same name. */ + format(name: string): number; + /** Empties the clipboard and places each entry as its own HGLOBAL. */ + setRaw(entries: RawEntry[]): void; + /** A copy of the HGLOBAL behind `format`, GlobalSize bytes long; null when absent. */ + getRaw(format: number): Buffer | null; +} + +let win32: Win32Clipboard | null = null; +if (isWindows && systemClipboard) { + try { + const user32 = dlopen("user32.dll", { + OpenClipboard: { args: [FFIType.ptr], returns: FFIType.i32 }, + CloseClipboard: { args: [], returns: FFIType.i32 }, + EmptyClipboard: { args: [], returns: FFIType.i32 }, + SetClipboardData: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.ptr }, + GetClipboardData: { args: [FFIType.u32], returns: FFIType.ptr }, + RegisterClipboardFormatW: { args: [FFIType.ptr], returns: FFIType.u32 }, + }).symbols; + const kernel32 = dlopen("kernel32.dll", { + GlobalAlloc: { args: [FFIType.u32, FFIType.u64], returns: FFIType.ptr }, + GlobalFree: { args: [FFIType.ptr], returns: FFIType.ptr }, + GlobalLock: { args: [FFIType.ptr], returns: FFIType.ptr }, + GlobalUnlock: { args: [FFIType.ptr], returns: FFIType.i32 }, + GlobalSize: { args: [FFIType.ptr], returns: FFIType.u64_fast }, + }).symbols; + + // Clipboard listeners (history, rdpclip) hold the clipboard briefly after + // every change; retry the way the backend does. + const withClipboardOpen = (fn: () => T): T => { + for (let attempt = 0; user32.OpenClipboard(null) === 0; attempt++) { + if (attempt === 50) throw new Error("OpenClipboard kept failing"); + Bun.sleepSync(2); + } + try { + return fn(); + } finally { + user32.CloseClipboard(); + } + }; + + const allocGlobal = ({ bytes, size = bytes.byteLength }: RawEntry) => { + const h = kernel32.GlobalAlloc(GHND, Math.max(size, bytes.byteLength, 1)); + if (h === null) throw new Error("GlobalAlloc failed"); + const p = kernel32.GlobalLock(h); + if (p === null) throw new Error("GlobalLock failed"); + if (bytes.byteLength > 0) toBuffer(p, 0, bytes.byteLength).set(bytes); + kernel32.GlobalUnlock(h); + return h; + }; + + // Proves this session can reach the clipboard at all; otherwise the block skips. + withClipboardOpen(() => {}); + + win32 = { + format(name) { + const id = user32.RegisterClipboardFormatW(ptr(Buffer.from(name + "\0", "utf16le"))); + if (id === 0) throw new Error(`RegisterClipboardFormatW(${name}) failed`); + return id; + }, + setRaw(entries) { + const handles = entries.map(entry => [entry.format, allocGlobal(entry)] as const); + // The system owns each handle once SetClipboardData accepts it. + let accepted = 0; + try { + withClipboardOpen(() => { + if (user32.EmptyClipboard() === 0) throw new Error("EmptyClipboard failed"); + for (const [format, h] of handles) { + if (user32.SetClipboardData(format, h) === null) throw new Error(`SetClipboardData(${format}) failed`); + accepted++; + } + }); + } finally { + for (const [, h] of handles.slice(accepted)) kernel32.GlobalFree(h); + } + }, + getRaw(format) { + return withClipboardOpen(() => { + const h = user32.GetClipboardData(format); + if (h === null) return null; + const size = Number(kernel32.GlobalSize(h)); + const out = Buffer.alloc(size); + if (size === 0) return out; + const p = kernel32.GlobalLock(h); + if (p === null) throw new Error("GlobalLock failed"); + try { + out.set(toBuffer(p, 0, size)); + } finally { + kernel32.GlobalUnlock(h); + } + return out; + }); + }, + }; + } catch (e) { + if (isCI) throw e; + console.error("skipping the Win32 backend tests:", (e as Error)?.message ?? e); + } +} + +describe.skipIf(!isWindows || win32 === null)("Win32 backend", () => { + const raw = () => win32!; + const utf16z = (text: string) => Buffer.from(text + "\0", "utf16le"); + let CF_HTML = 0; + let CF_PNG = 0; + let CF_IMAGE_PNG = 0; + beforeAll(() => { + CF_HTML = raw().format("HTML Format"); + CF_PNG = raw().format("PNG"); + CF_IMAGE_PNG = raw().format("image/png"); + }); + + // Every representation of every item, in a shape toEqual can diff whole. + async function readAll(): Promise[]> { + const items = await navigator.clipboard.read(); + return Promise.all( + items.map(async item => { + const out: Record = { types: [...item.types] }; + for (const type of item.types) { + const blob = await item.getType(type); + out[type] = type === "image/png" ? Buffer.from(await blob.arrayBuffer()) : await blob.text(); + } + return out; + }), + ); + } + + // A CF_HTML payload as another producer might lay it out: Version:1.0, + // 6-digit fields, an optional extra header line. The offsets locate + // `fragment` inside `body` in bytes; `overrides` replaces fields verbatim + // for the malformed cases. + function foreignCfHtml(body: string, fragment: string, overrides: Record = {}, extraHeader = "") { + const fields = ["StartHTML", "EndHTML", "StartFragment", "EndFragment"]; + const headerLength = Buffer.byteLength( + `Version:1.0\r\n${fields.map(f => `${f}:000000\r\n`).join("")}${extraHeader}`, + ); + const bodyBytes = Buffer.from(body); + const fragmentAt = bodyBytes.indexOf(Buffer.from(fragment)); + if (fragmentAt < 0) throw new Error("fragment is not in body"); + const offsets: Record = { + StartHTML: headerLength, + EndHTML: headerLength + bodyBytes.length, + StartFragment: headerLength + fragmentAt, + EndFragment: headerLength + fragmentAt + Buffer.byteLength(fragment), + }; + const header = `Version:1.0\r\n${fields + .map(f => `${f}:${overrides[f] ?? String(offsets[f]).padStart(6, "0")}\r\n`) + .join("")}${extraHeader}`; + return Buffer.concat([Buffer.from(header), bodyBytes]); + } + + test("read() extracts a foreign CF_HTML fragment from the header byte offsets", async () => { + // No fragment markers, so only the offsets can locate it; the non-ASCII + // header line and fragment make byte offsets differ from char offsets. + const fragment = "

naïve ☃ 日本

"; + const payload = foreignCfHtml( + `t\r\n${fragment}\r\n`, + fragment, + {}, + "SourceURL:https://example.com/ü\r\n", + ); + raw().setRaw([{ format: CF_HTML, bytes: payload }]); + expect(await readAll()).toEqual([{ types: ["text/html"], "text/html": fragment }]); + expect(await navigator.clipboard.readText()).toBe(""); + }); + + test("valid header offsets win over the fragment markers", async () => { + // Producers such as Excel put context inside the markers and point the + // offsets at the real selection; the offsets are authoritative. + const payload = foreignCfHtml( + `${START_MARK}
inner
${END_MARK}`, + "inner", + ); + raw().setRaw([{ format: CF_HTML, bytes: payload }]); + expect(await readAll()).toEqual([{ types: ["text/html"], "text/html": "inner" }]); + }); + + test("unusable header offsets fall back to the fragment markers", async () => { + const body = `${START_MARK}marked ü${END_MARK}`; + const fragment = "marked ü"; + const malformed: Record[] = [ + { EndFragment: "9999999999" }, + { StartFragment: "000090", EndFragment: "000050" }, + { StartFragment: "-1", EndFragment: "garbage" }, + { StartFragment: "99999999999999999999999" }, + ]; + const results: unknown[] = []; + for (const overrides of malformed) { + raw().setRaw([{ format: CF_HTML, bytes: foreignCfHtml(body, fragment, overrides) }]); + results.push(await readAll()); + } + expect(results).toEqual(malformed.map(() => [{ types: ["text/html"], "text/html": fragment }])); + }); + + test("CF_HTML with neither usable offsets nor markers reads as absent", async () => { + const broken = [ + foreignCfHtml("unreachable", "unreachable", { + StartFragment: "x", + EndFragment: "y", + }), + // Not an envelope at all; some producers put bare markup there. + Buffer.from("bare markup"), + ]; + for (const payload of broken) { + raw().setRaw([{ format: CF_HTML, bytes: payload }]); + expect(await readAll()).toEqual([]); + // The unparsable representation does not take the others down with it. + raw().setRaw([ + { format: CF_HTML, bytes: payload }, + { format: CF_UNICODETEXT, bytes: utf16z("still here") }, + ]); + expect(await readAll()).toEqual([{ types: ["text/plain"], "text/plain": "still here" }]); + } + }); + + test("CF_HTML is parsed up to the first NUL of the allocation", async () => { + const fragment = "padded"; + const padded = foreignCfHtml(`${START_MARK}${fragment}${END_MARK}`, fragment); + raw().setRaw([{ format: CF_HTML, bytes: padded, size: padded.length + 64 }]); + expect(await readAll()).toEqual([{ types: ["text/html"], "text/html": fragment }]); + + // Markers that only exist past the terminator are not part of the payload. + const beforeNul = foreignCfHtml("no markers", "no markers", { EndFragment: "x" }); + const stale = Buffer.concat([beforeNul, Buffer.from("\0"), Buffer.from(`${START_MARK}stale${END_MARK}`)]); + raw().setRaw([{ format: CF_HTML, bytes: stale }]); + expect(await readAll()).toEqual([]); + }); + + test("raw CF_UNICODETEXT: embedded NUL, unpaired surrogate, missing terminator, empty string", async () => { + const cases: [name: string, bytes: Buffer][] = [ + ["embedded NUL", utf16z("a\0b")], + ["unpaired surrogate", Buffer.from(new Uint16Array([0xd800, 0x78, 0]).buffer)], + // Exact-size allocation with no terminator: the read stops at GlobalSize. + // Windows may hand back its own copy of text data with the last unit + // overwritten by a terminator (observed on a machine with the clipboard + // history service), so a correct read sees "h" there and "hi" elsewhere. + ["no terminator", Buffer.from("hi", "utf16le")], + ["only a terminator", utf16z("")], + ]; + const results: Record = {}; + for (const [name, bytes] of cases) { + raw().setRaw([{ format: CF_UNICODETEXT, bytes }]); + results[name] = { text: await navigator.clipboard.readText(), items: await readAll() }; + } + const present = (text: unknown) => ({ text, items: [{ types: ["text/plain"], "text/plain": text }] }); + expect(results).toEqual({ + "embedded NUL": present("a"), + "unpaired surrogate": present("\uFFFDx"), + "no terminator": present(expect.stringMatching(/^hi?$/)), + "only a terminator": present(""), + }); + }); + + test("CF_TEXT from an ANSI producer is read through the CF_UNICODETEXT Windows synthesizes", async () => { + raw().setRaw([{ format: CF_TEXT, bytes: Buffer.from("ansi only\0", "latin1") }]); + expect(await navigator.clipboard.readText()).toBe("ansi only"); + expect(await readAll()).toEqual([{ types: ["text/plain"], "text/plain": "ansi only" }]); + }); + + test("an emptied clipboard, or one holding only a private format, reads as '' and []", async () => { + await navigator.clipboard.writeText("about to be emptied"); + raw().setRaw([]); + expect([await navigator.clipboard.readText(), await readAll()]).toEqual(["", []]); + + const privateFormat = raw().format("Bun.Test.Private"); + raw().setRaw([{ format: privateFormat, bytes: Buffer.from("private payload") }]); + expect(raw().getRaw(privateFormat)?.toString()).toBe("private payload"); + expect([await navigator.clipboard.readText(), await readAll()]).toEqual(["", []]); + }); + + test('a PNG registered as "image/png" is read; write() places it as "PNG" and reads it back exactly', async () => { + raw().setRaw([{ format: CF_IMAGE_PNG, bytes: PNG_1X1 }]); + expect(raw().getRaw(CF_PNG)).toBeNull(); + expect(await readAll()).toEqual([{ types: ["image/png"], "image/png": PNG_1X1 }]); + + await navigator.clipboard.write([new ClipboardItem({ "image/png": new Blob([PNG_1X1], { type: "image/png" }) })]); + expect(raw().getRaw(CF_IMAGE_PNG)).toBeNull(); + // GlobalSize rounds up to the allocator's granularity; the stream itself + // is what write() placed, and read() trims the slack off at IEND. + const placed = raw().getRaw(CF_PNG)!; + expect(placed.subarray(0, PNG_1X1.length)).toEqual(PNG_1X1); + expect(placed.length - PNG_1X1.length).toBeLessThan(16); + expect(await readAll()).toEqual([{ types: ["image/png"], "image/png": PNG_1X1 }]); + + // The same image as a bitmap for consumers that only read those: a + // bottom-up BITMAPV5HEADER DIB holding PNG_1X1's pixel as straight-alpha BGRA. + const dib = raw().getRaw(CF_DIBV5)!; + expect({ + headerSize: dib.readUInt32LE(0), + width: dib.readInt32LE(4), + height: dib.readInt32LE(8), + bitCount: dib.readUInt16LE(14), + pixel: [...dib.subarray(124, 128)], + }).toEqual({ headerSize: 124, width: 1, height: 1, bitCount: 32, pixel: [255, 0, 0, 127] }); + }); + + // A bitmap-only clipboard (a screenshot, Paint) still reads as image/png: + // a BITMAPINFOHEADER, an optional colour table, then one pixel in a + // 4-byte-padded row. + test.each([ + { name: "24-bit", bitCount: 24, paletteEntries: 0, colorsUsed: 0 }, + { name: "8-bit with a colour table", bitCount: 8, paletteEntries: 256, colorsUsed: 0 }, + { name: "8-bit with a short colour table (biClrUsed)", bitCount: 8, paletteEntries: 2, colorsUsed: 2 }, + ])("$name DIB reads as image/png", async ({ bitCount, paletteEntries, colorsUsed }) => { + const dib = Buffer.alloc(40 + paletteEntries * 4 + 4); + dib.writeUInt32LE(40, 0); + dib.writeInt32LE(1, 4); + dib.writeInt32LE(1, 8); + dib.writeUInt16LE(1, 12); + dib.writeUInt16LE(bitCount, 14); + dib.writeUInt32LE(4, 20); + dib.writeUInt32LE(colorsUsed, 32); + // Blue: BGR bytes, or palette entry 0 (the pixel is index 0). + dib.set([255, 0, 0], 40); + raw().setRaw([{ format: CF_DIB, bytes: dib }]); + const [item] = await readAll(); + const png = item["image/png"] as Buffer; + // The first scanline of an 8-bit truecolour PNG: a filter byte, then RGB(A). + const idat: Buffer[] = []; + for (let at = 8; at < png.length; at += 12 + png.readUInt32BE(at)) { + if (png.toString("latin1", at + 4, at + 8) === "IDAT") + idat.push(png.subarray(at + 8, at + 8 + png.readUInt32BE(at))); + } + expect({ + types: item.types, + signature: png.subarray(0, 8).toString("hex"), + size: [png.readUInt32BE(16), png.readUInt32BE(20)], + rgb: [...inflateSync(Buffer.concat(idat)).subarray(1, 4)], + }).toEqual({ types: ["image/png"], signature: "89504e470d0a1a0a", size: [1, 1], rgb: [0, 0, 255] }); + }); + + // The RGB of every pixel of an 8-bit truecolour PNG, row by row: + // https://www.w3.org/TR/png-3/#9Filters + function pngPixels(png: Buffer) { + const width = png.readUInt32BE(16); + const height = png.readUInt32BE(20); + const channels = png[25] === 6 ? 4 : 3; + const idat: Buffer[] = []; + for (let at = 8; at < png.length; at += 12 + png.readUInt32BE(at)) { + if (png.toString("latin1", at + 4, at + 8) === "IDAT") + idat.push(png.subarray(at + 8, at + 8 + png.readUInt32BE(at))); + } + const data = inflateSync(Buffer.concat(idat)); + const stride = width * channels; + const rows: number[][][] = []; + let previous = new Uint8Array(stride); + for (let y = 0; y < height; y++) { + const filter = data[y * (stride + 1)]; + const line = Uint8Array.from(data.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))); + for (let i = 0; i < stride; i++) { + const a = i >= channels ? line[i - channels] : 0; + const b = previous[i]; + const c = i >= channels ? previous[i - channels] : 0; + const [pa, pb, pc] = [Math.abs(b - c), Math.abs(a - c), Math.abs(a + b - 2 * c)]; + const paeth = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + line[i] += [0, a, b, (a + b) >> 1, paeth][filter]; + } + rows.push(Array.from({ length: width }, (_, x) => [...line.subarray(x * channels, x * channels + 3)])); + previous = line; + } + return { width, height, rows }; + } + + // Windows offers CF_DIBV5 for every bitmap. For a BI_BITFIELDS one that it + // synthesizes, it repeats the three colour masks after the V5 header, which + // already holds them. A producer's own CF_DIBV5 has no such repeat. A reader + // that gets either case wrong shifts the image by three pixels. One pixel + // cannot show that: two rows show the bottom-up order, and three 32-bit + // columns make a row as long as the masks. + test.each([ + { name: "a CF_DIB, whose synthesized CF_DIBV5 repeats the masks", format: CF_DIB, headerSize: 40 }, + { name: "a producer's own CF_DIBV5, which does not", format: CF_DIBV5, headerSize: 124 }, + ])("a BI_BITFIELDS bitmap reads back with every pixel in place: $name", async ({ format, headerSize }) => { + const rows = [ + [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + [ + [10, 20, 30], + [200, 100, 50], + [255, 255, 255], + ], + ]; + const masks = [0x00ff0000, 0x0000ff00, 0x000000ff]; + // A 40-byte header is followed by its masks; a V5 header holds them. + const pixelsAt = headerSize === 40 ? 52 : 124; + const dib = Buffer.alloc(pixelsAt + 24); + dib.writeUInt32LE(headerSize, 0); + dib.writeInt32LE(3, 4); + dib.writeInt32LE(2, 8); + dib.writeUInt16LE(1, 12); + dib.writeUInt16LE(32, 14); + dib.writeUInt32LE(3, 16); // BI_BITFIELDS + dib.writeUInt32LE(24, 20); + masks.forEach((mask, i) => dib.writeUInt32LE(mask, 40 + i * 4)); + if (headerSize === 124) { + dib.write("BGRs", 56, "latin1"); // LCS_sRGB + dib.writeUInt32LE(4, 108); // LCS_GM_IMAGES + } + // Bottom row first, each pixel as B, G, R and a fourth byte no mask covers. + [...rows] + .reverse() + .flat() + .forEach(([r, g, b], i) => dib.set([b, g, r, 255], pixelsAt + i * 4)); + raw().setRaw([{ format, bytes: dib }]); + + const offered = raw().getRaw(CF_DIBV5)!; + expect({ + headerSize: offered.readUInt32LE(0), + masksRepeated: offered.subarray(124, 136).equals(offered.subarray(40, 52)), + }).toEqual({ headerSize: 124, masksRepeated: headerSize === 40 }); + + const [item] = await readAll(); + expect(pngPixels(item["image/png"] as Buffer)).toEqual({ width: 3, height: 2, rows }); + }); + + test("a PNG placed by another process reads back byte-exact", async () => { + // Data set by another process arrives through the kernel's copy, where + // the allocation size is not ours to control. + raw().setRaw([]); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `await navigator.clipboard.write([new ClipboardItem({ "image/png": new Blob([Buffer.from(${JSON.stringify(PNG_1X1.toString("base64"))}, "base64")], { type: "image/png" }) })])`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: expect.any(String), exitCode: 0 }); + expect(await readAll()).toEqual([{ types: ["image/png"], "image/png": PNG_1X1 }]); + }); + + test("write() produces a CF_HTML envelope an independent reader can parse", async () => { + const fragment = "x & y ü"; + await navigator.clipboard.write([new ClipboardItem({ "text/html": fragment })]); + const block = raw().getRaw(CF_HTML)!; + const nul = block.indexOf(0); + const payload = block.subarray(0, nul < 0 ? block.length : nul); + const text = payload.toString("latin1"); // one char per byte, so the fields index it directly + const field = (name: string) => Number(new RegExp(`^${name}:(\\d{10})\\r\\n`, "m").exec(text)?.[1]); + const [startHtml, endHtml, startFragment, endFragment] = [ + "StartHTML", + "EndHTML", + "StartFragment", + "EndFragment", + ].map(field); + const digits = (n: number) => String(n).padStart(10, "0"); + expect({ + nulTerminated: nul >= 0, + header: payload.subarray(0, startHtml).toString(), + html: payload.subarray(startHtml, endHtml).toString(), + fragment: payload.subarray(startFragment, endFragment).toString(), + fragmentBytes: endFragment - startFragment, + endHtml, + }).toEqual({ + // strlen-based consumers (.NET's DataObject) need the terminator. + nulTerminated: true, + header: `Version:0.9\r\nStartHTML:${digits(startHtml)}\r\nEndHTML:${digits(endHtml)}\r\nStartFragment:${digits(startFragment)}\r\nEndFragment:${digits(endFragment)}\r\n`, + html: `\r\n\r\n${START_MARK}${fragment}${END_MARK}\r\n\r\n`, + fragment, + // ü is two bytes: the fields count bytes, not characters. + fragmentBytes: Buffer.byteLength(fragment), + endHtml: payload.length, + }); + expect(await readAll()).toEqual([{ types: ["text/html"], "text/html": fragment }]); + }); + + test("writeText() places CRLF-normalized, NUL-terminated CF_UNICODETEXT", async () => { + const cases: [input: string, placed: string][] = [ + ["a\nb\r\nc", "a\r\nb\r\nc\0"], + ["\n\n", "\r\n\r\n\0"], + ["tab\there ü\r", "tab\there ü\r\0"], + ]; + const results: string[] = []; + for (const [input] of cases) { + await navigator.clipboard.writeText(input); + // Up to and including the terminator; GlobalSize may add zeroed slack. + const units = raw().getRaw(CF_UNICODETEXT)!.toString("utf16le"); + results.push(units.slice(0, units.indexOf("\0") + 1)); + } + expect(results).toEqual(cases.map(([, placed]) => placed)); + }); + + test("one write() places every representation; writeText() then replaces all of them", async () => { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": "plain", + "text/html": "plain", + "image/png": new Blob([PNG_1X1], { type: "image/png" }), + }), + ]); + expect(await readAll()).toEqual([ + { + types: ["text/plain", "text/html", "image/png"], + "text/plain": "plain", + "text/html": "plain", + "image/png": PNG_1X1, + }, + ]); + + await navigator.clipboard.writeText("text again"); + expect(await readAll()).toEqual([{ types: ["text/plain"], "text/plain": "text again" }]); + expect([raw().getRaw(CF_HTML), raw().getRaw(CF_PNG), raw().getRaw(CF_DIBV5)]).toEqual([null, null, null]); + }); + + test("interoperates with clip.exe and Get-Clipboard", async () => { + const fromClip = `from clip.exe ${Date.now()}`; + await using clip = Bun.spawn({ cmd: ["clip.exe"], env: bunEnv, stdin: "pipe", stdout: "pipe", stderr: "pipe" }); + clip.stdin.write(fromClip); + await clip.stdin.end(); + expect(await Promise.all([clip.stdout.text(), clip.stderr.text(), clip.exited])).toEqual([ + expect.any(String), + expect.any(String), + 0, + ]); + expect(await navigator.clipboard.readText()).toBe(fromClip); + + const fromBun = `from bun ${Date.now()}`; + await navigator.clipboard.writeText(fromBun); + await using powershell = Bun.spawn({ + cmd: ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + powershell.stdout.text(), + powershell.stderr.text(), + powershell.exited, + ]); + expect({ stdout: stdout.trimEnd(), stderr, exitCode }).toEqual({ + stdout: fromBun, + stderr: expect.any(String), + exitCode: 0, + }); + }); +}); diff --git a/test/parallel-denylist.txt b/test/parallel-denylist.txt index 6268f0f38f8d..44ccbf0db71c 100644 --- a/test/parallel-denylist.txt +++ b/test/parallel-denylist.txt @@ -164,6 +164,7 @@ js/third_party/pnpm/pnpm.test.ts js/third_party/postgres/postgres.test.ts js/third_party/socket.io/socket.io-handshake.test.ts js/third_party/stripe/stripe.test.ts +js/web/clipboard/clipboard.test.ts js/web/console/console-log.test.ts js/web/explicit-resource-management.test.ts js/web/fetch/fetch-proxy-tls-intern-race.test.ts