16 KiB
06 — Data Device, Selection, DnD (wl_data_device)
This chapter covers Greenfield's data device / clipboard / drag-and-drop
implementation: how wl_data_device_manager, wl_data_source,
wl_data_offer, wl_data_device are implemented, how clipboard contents
are transferred between browser and native apps, and how DnD is coordinated.
Faithful Wayland model
DataDeviceManager is a v3 global singleton. wl_data_device is per
seat-per-client and the implementation lives directly on Seat (which
implements WlDataDeviceRequests). WaylandDataSource / DataOffer are
the standard source/offer pair, ported almost verbatim from weston,
including the chooseAction() bit-trick for DnD action negotiation.
The FD-over-WebSocket problem
Wayland's wl_data_offer.receive(mimeType, fd) /
wl_data_source.send(mimeType, fd) carry a real UNIX fd. Greenfield solves
this with a ProxyFD JSON descriptor:
ProxyFD = {
handle: number,
type: 'pipe-read' | 'pipe-write' | 'shm' | 'unknown',
host: string // proxy's HTTP base URL
}
Three concrete InputOutput / InputOutputFD implementations:
webInputOutput — for in-browser web apps
Pipes are MessageChannel ports, transferred as ArrayBuffers. All
"fd" operations are in-browser zero-copy transfers.
RemoteInputOutput — for native apps
All fd operations go over a secondary WebSocket to endpoints:
/mkfifo, /mkstemp-mmap, /read-fd-as-stream, /write-fd-as-stream,
/read-fd, /close-fd.
ProxyInputOutput (proxy-side) — owns the real OS pipes and memfd files
proxyFDtoNativeFD(proxyFD) returns the raw int fd because every ProxyFD
has host === proxy.baseURL.
The proxy interceptors for wl_data_source / wl_data_offer /
wl_data_device / wl_data_device_manager are auto-generated no-ops that
only handle resource construction and lifetime — all actual semantics live
in the browser compositor.
Crucial consequence
For native→native clipboard transfers (e.g. user copies in one Linux app, pastes in another), the browser compositor never sees a single byte of the data — it only relays the fd reference. The actual content stays inside the proxy host's OS kernel pipe.
MIME-type negotiation
At the Wayland level, mime types are just relayed strings. Mapping to the
browser's hard-coded allow-list ['text/plain', 'text/html', 'image/png']
happens only at the DOM boundary:
Native → browser
selection.ts:handleWaylandDataSourceUpdate: each source mime type is
substring-matched against the allow-list (text/plain;charset=utf-8
contains text/plain, so it maps), survivors are pulled as separate blobs
via mkfifo + send + readBlob, and finally
navigator.clipboard.write([new ClipboardItem(...)]).
Browser → native
BrowserDataSource.ts: ClipboardItem.types become the source mime types,
plus a synthetic text/plain;charset=utf-8 alias is appended (Linux apps
expect the charset form). On send, the alias is normalised back to
text/plain for the lookup.
Caveat: substring matching is fragile
The substring match dataSourceMimeType.indexOf(browserMimeType) !== -1
can collide (image/png matching image/png;base64 is fine, but
text/plain;charset=iso-8859-1 silently becomes text/plain). Consider
an explicit prefix or exact-match policy in your implementation.
Clipboard mirroring is pull-based
The browser has no clipboardchange event (Chromium issue 933608 is
cited in the code). Greenfield polls navigator.clipboard.read():
- on init
- on every
window.focusevent
De-duplicates offers by mime-type set + total blob size, then calls
seat.setSelectionInternal(createBrowserDataSource(offers), serial).
The feedback loop is broken because handleWaylandDataSourceUpdate returns
early when dataSource instanceof BrowserDataSource.
Firefox fallback
text-selection.ts uses only readText / writeText and a
BrowserTextDataSource that serves text/plain;charset=utf-8 — no images
or HTML.
Drag-and-drop
Wayland-side (Seat.startDrag)
- Validates the implicit pointer grab.
- Attaches
DragIconRoleto the optional icon surface. - Installs a
PointerDraggrab on both pointer and keyboard.
PointerDrag tracks the focused data device, sends
wl_data_device.enter / leave / motion / drop, creates fresh
wl_data_offers via sendOffer, and runs the v3 action negotiation.
Browser-side (BrowserDnD class)
Creates a hidden full-canvas dragHelper div with draggable=true when
the Wayland drag-icon surface commits, so the browser shows the Wayland icon
via event.dataTransfer.setDragImage(cursorCanvas, ...). While the user
drags, dragover events on the dragHelper are converted back to
ButtonEvents and pushed through session.inputQueue.queueMotion, keeping
PointerDrag fed.
Not implemented: dragging data FROM a Wayland app INTO a real DOM target
The FIXME in dnd.ts admits: dragging data FROM a Wayland app INTO a real
DOM drop target (file drag-out, etc.) is not implemented.
XWayland bridge
XWindowManager listens on the X11 CLIPBOARD atom via XFixes and
builds XDataSource (clipboard) or XDnDDataSource (XDnD). When the
receiver pulls data:
XDataSource.sendcallsXConvertSelectionto have the X server push the bytes into a property onselectionWindow.writePropertycopies them into the receiver'sInputOutputFD.- INCR transfers are handled (
getIncrChunk,sendIncrChunk).
Primary selection is NOT implemented
A grep for primary_selection, gtk_primary, zwp_primary_selection
across the entire packages/ tree returns zero hits. XWayland only
watches the CLIPBOARD atom, not PRIMARY. Middle-click paste does
not work in Greenfield today.
For the user: if your users expect Linux-style middle-click paste,
you'll need to implement zwp_primary_selection_device_manager_v1 (or
gtk_primary_selection) on the compositor side and forward PRIMARY
notifications in XWindowManager.
ASCII diagram — clipboard flow in both directions
CLIPBOARD / SELECTION FLOW
=================================================================
A) NATIVE APP COPIES ──► BROWSER DOM CLIPBOARD (Ctrl+C in Linux app)
=================================================================
┌──────────────┐ wl_data_device.set_selection(src,serial) ┌────────────────────────┐
│ Native app │ ─────────────────────────────────────────────► │ compositor │
│ (Wayland or │ │ (browser, in DOM) │
│ XWayland) │ ◄────── wl_data_offer.offer("text/plain") ─── │ │
└──────────────┘ (only if this app has keyboard focus)└───────────┬────────────┘
│ selectionListeners fire
▼
┌──────────────────────────┐
│ handleWaylandDataSource │
│ Update() (selection.ts)│
└──────────┬──────────────┘
mkfifo() ──────────────────────────► │ proxy /mkfifo
ProxyFD read+write (real OS pipe) ◄───── returns 2 ProxyFDs
▼
dataSource.send("text/plain", writeFD)
│
▼
wl_data_source.send("text/plain", writeFDProxyFD)
─────────────────────────────────────────► │ Native source
│ receives real write fd
│ (proxyFDtoNativeFD
│ is a passthrough)
▼
Native app writes bytes
──────────────► OS pipe ───┐
│
readFD.readBlob() ◄──────────── read from /read-fd-as-stream ◄─┘
▼ (proxy streams over WS)
Blob {type:"text/plain"}
▼
navigator.clipboard.write([ new ClipboardItem({"text/plain": blob}) ])
▼
┌────────────────────────────┐
│ DOM clipboard populated │
│ (Ctrl+V anywhere now works) │
└────────────────────────────┘
=================================================================
B) USER COPIES IN BROWSER ──► NATIVE APP GETS SELECTION (Ctrl+C in DOM)
=================================================================
┌─────────────────────────────┐
│ User copies in DOM │
│ (browser tab, textarea...) │
└──────────────┬──────────────┘
│ on window 'focus' event (or initial load)
▼
navigator.clipboard.read() ──► ClipboardItems[]
▼
handleBrowserDataSourceUpdate() — de-dup vs previous offers
▼
createBrowserDataSource(offers)
.mimeTypes = offer.types + ['text/plain;charset=utf-8'] alias
▼
seat.setSelectionInternal(browserDataSource, serial)
│
│ 1) selectionListeners fire — handleWaylandDataSourceUpdate
│ returns early (instanceof BrowserDataSource), breaking loop
│
│ 2) sendSelection(focusedClient)
▼
┌────────────────────────┐ wl_data_offer.offer("text/plain"), ┌──────────────┐
│ compositor │ ─ wl_data_device.selection(offer) ──► │ Native app │
│ (browser, in DOM) │ │ (focused) │
└───────────┬────────────┘ └──────┬───────┘
│ │
│ When the native app calls wl_data_offer.receive("text/plain", fd):
│ │
│ ◄──────── wl_data_offer.receive(mimeType, writeFDProxyFD) ◄── native pipe
│ (fd was created by the native app's libwayland; proxy
│ wrapped it as ProxyFD{host:proxy.baseURL, type:'unknown'}
│ then compositor's DataOffer.receive calls wrapFD → RemoteInputOutputFD)
▼
BrowserDataSource.send(mimeType, ioFD)
│
▼
matchingOffer.getType(mimeType) ─► Promise<Blob> (from ClipboardItem)
│
▼
ioFD.write(blob) ─► POST /write-fd-as-stream?fd=<handle> ─► proxy
│
▼
createWriteStream({fd, autoClose:true})
ws stream ──► native write fd
│
▼
Bytes flow through OS pipe into native app
│
▼
ioFD.close() ─► /close-fd ─► EOF ─► native app's read returns 0
=================================================================
PROXY FD TRANSLATION (works the same way in B and A)
=================================================================
Native fd (in proxy process)
│
│ wrap into ProxyFD JSON: {handle, type, host: proxy.baseURL}
│ JSON-encode, length-prefix, send over WS protocol channel
▼
┌─────────────────────────────────────────────────┐
│ Browser compositor │
│ client.userData.inputOutput.wrapFD(fd, type) │
│ → RemoteInputOutputFD (RemoteInputOutput) │
│ → WebPipeInputOutputFD (webInputOutput, │
│ for in-browser apps)│
└─────────────────────────────────────────────────┘
File reference
| File | Role |
|---|---|
packages/compositor/src/DataDeviceManager.ts |
wl_data_device_manager global |
packages/compositor/src/DataSource.ts |
wl_data_source impl, the provider side |
packages/compositor/src/DataOffer.ts |
wl_data_offer impl, the receiver side |
packages/compositor/src/WaylandDataSource.ts |
Wrapper around native wl_data_source events |
packages/compositor/src/Seat.ts |
wl_data_device impl (Seat implements WlDataDeviceRequests) |
packages/compositor/src/browser/selection.ts |
Browser clipboard bridge (native → DOM) |
packages/compositor/src/browser/text-selection.ts |
Firefox fallback (text-only) |
packages/compositor/src/browser/dnd.ts |
Browser DnD bridge (Wayland → DOM) |
packages/compositor/src/InputOutput.ts |
FD abstraction interface |
packages/compositor/src/remote/RemoteInputOutput.ts |
FD-over-WebSocket for native apps |
packages/compositor/src/remote/XWindowManager.ts |
X11 CLIPBOARD atom watcher |
packages/compositor/src/remote/XDataSource.ts |
X11 clipboard source (XWayland apps) |
packages/compositor/src/remote/XDnDDataSource.ts |
X11 DnD source |
Next actions / suggested improvements
- Primary selection — implement
zwp_primary_selection_device_managerv1 (orgtk_primary_selection) on the compositor side and forwardPRIMARYnotifications inXWindowManager. This unlocks middle-click paste from native apps. clipboardchangeevent — when browsers ship it, replace thewindow.focus-based polling inselection.tswith a proper event-driven subscription.- Native→DOM DnD — implement the dataTransfer side of
BrowserDnDso a Wayland drag can deliver bytes to a real DOM drop target (thednd.tsFIXME). - MIME allow-list discovery — replace the hard-coded
['text/plain', 'text/html', 'image/png']with a runtime check once browsers expose the supported types. - MIME-type strict matching — the substring match
dataSourceMimeType.indexOf(browserMimeType) !== -1can collide (image/pngmatchingimage/png;base64is fine, buttext/plain;charset=iso-8859-1silently becomestext/plain). Consider an explicit prefix or exact-match policy.