Files
one-os-dev-wayland-html5-co…/06-data-clipboard.md
T

14 KiB

06 — Data Device, Selection, DnD (wl_data_device)

This chapter covers the data device / clipboard / drag-and-drop layer in the MIT approach. Same architecture as Greenfield (clean-room), but with primary selection added (which Greenfield lacks).

Faithful Wayland model + primary selection (NEW)

DataDeviceManager is a v3 global singleton. wl_data_device is per seat-per-client and the implementation lives directly on Seat. WaylandDataSource / DataOffer are the standard source/offer pair.

NEW: Implement zwp_primary_selection_device_manager_v1 for middle-click paste, which Greenfield lacks entirely (grep returns zero hits).

FD-over-WebSocket workaround (REUSED approach)

Same ProxyFD JSON descriptor as Greenfield:

ProxyFD = { handle: number, type: 'pipe-read'|'pipe-write'|'shm'|'unknown', host: string }

Three concrete InputOutput / InputOutputFD implementations:

webInputOutput — for in-browser web apps

Pipes are MessageChannel ports, transferred as ArrayBuffers.

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 (Python side) — owns the real OS pipes and memfds

In Python:

  • os.pipe() for pipes
  • os.memfd_create() for shared memory (replaces Greenfield's createMemoryMappedFile native addon)

proxyFDtoNativeFD(proxyFD) returns the raw int fd because every ProxyFD has host === proxy.baseURL.

Crucial consequence: For native→native clipboard transfers (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 (IMPROVED over Greenfield)

Same hard-coded allow-list initially: ['text/plain', 'text/html', 'image/png']. IMPROVEMENT: use exact prefix matching, not substring matching (Greenfield's substring match can collide — text/plain;charset=iso-8859-1 silently becomes text/plain).

Native → browser

selection.ts:handleWaylandDataSourceUpdate: each source mime type is matched against the allow-list (exact prefix match), survivors pulled as separate blobs via mkfifo + send + readBlob, then 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 appended (Linux apps expect the charset form). On send, the alias is normalised back to text/plain for the lookup.

Clipboard mirroring (REUSED approach)

The browser has no clipboardchange event (Chromium issue 933608 cited in Greenfield's code). MIT approach polls navigator.clipboard.read():

  • on init
  • on every window.focus event

De-duplicates offers by mime-type set + total blob size, then calls seat.setSelectionInternal(createBrowserDataSource(offers), serial).

The feedback loop is broken (same as Greenfield) because handleWaylandDataSourceUpdate returns early when dataSource instanceof BrowserDataSource. Watch for the clipboardchange event in modern browsers (currently behind flags in Chrome) and switch to event-driven when available.

Firefox fallback (REUSED approach)

text-selection.ts uses only readText/writeText and a BrowserTextDataSource that serves text/plain;charset=utf-8 — no images or HTML.

Drag-and-drop (REUSED approach + NEW)

Wayland-side (Seat.startDrag)

  1. Validates the implicit pointer grab.
  2. Attaches DragIconRole to the optional icon surface.
  3. Installs a PointerDrag grab 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.

NEW: Native→DOM DnD — implement the dataTransfer side of BrowserDnD so a Wayland drag can deliver bytes to a real DOM drop target. Greenfield has a FIXME admitting this isn't implemented.

XWayland bridge (REUSED approach, IF XWayland enabled)

XWindowManager listens on the X11 CLIPBOARD atom via XFixes and builds XDataSource (clipboard) or XDnDDataSource (XDnD). When the receiver pulls data:

  1. XDataSource.send calls XConvertSelection to have the X server push the bytes into a property on selectionWindow.
  2. writeProperty copies them into the receiver's InputOutputFD.
  3. INCR transfers are handled (getIncrChunk, sendIncrChunk).

Uses @gfld/xtsb (MIT, REUSED) for X11 protocol bindings.

Primary selection (NEW — Greenfield lacks this)

Implement zwp_primary_selection_device_manager_v1 (or the older gtk_primary_selection) on the compositor side. Forward PRIMARY notifications in XWindowManager. This unlocks middle-click paste from native apps.

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        │
│  (sandboxed  │                                                │  (browser, in DOM)     │
│   in bwrap)  │ ◄────── wl_data_offer.offer("text/plain") ───  │                        │
└──────────────┘            (only if this app has keyboard focus)└───────────┬────────────┘
                                                                         │ selectionListeners fire
                                                                         ▼
                                                          ┌──────────────────────────┐
                                                          │ handleWaylandDataSource  │
                                                          │   Update() (selection.ts)│
                                                          └──────────┬──────────────┘
                                  mkfifo()  ──────────────────────────► │ Python /mkfifo
                                  ProxyFD read+write (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
            ▼
   BrowserDataSource.send(mimeType, ioFD)
            │
            ▼
   matchingOffer.getType(mimeType) ─► Promise<Blob>  (from ClipboardItem)
            │
            ▼
   ioFD.write(blob) ─► POST /write-fd-as-stream?fd=<handle>  ─► Python proxy
                                                              │
                                                              ▼
                                          asyncio.StreamWriter ──► native write fd
                                                              │
                                                              ▼
                                          Bytes flow through OS pipe into native app
            │
            ▼
   ioFD.close() ─► /close-fd  ─► EOF ─► native app's read returns 0


=================================================================
C) PRIMARY SELECTION (NEW — middle-click paste)  ──►  same flow but via
   zwp_primary_selection_device_manager_v1 instead of wl_data_device
=================================================================

   Native app selects text ──► zwp_primary_selection_source.set_selection
                                              │
                                              ▼
   Browser receives primary selection offer ──► stores in seat.primarySelectionDataSource
                                              │
                                              ▼
   User middle-clicks on a surface ──► seat.notifyPrimarySelectionRequest
                                              │
                                              ▼
   primarySelectionSource.send(mimeType, writeFD) ──► same FD-over-WS flow as A/B

File reference

Custom (clean-room TS)

File Role
packages/compositor/src/data/DataDeviceManager.ts wl_data_device_manager global
packages/compositor/src/data/DataSource.ts wl_data_source impl
packages/compositor/src/data/DataOffer.ts wl_data_offer impl
packages/compositor/src/data/WaylandDataSource.ts Native source wrapper
packages/compositor/src/data/PrimarySelectionDeviceManager.ts NEW: zwp_primary_selection_v1
packages/compositor/src/data/PrimarySelectionSource.ts NEW: middle-click paste source
packages/compositor/src/browser/selection.ts Browser clipboard bridge
packages/compositor/src/browser/text-selection.ts Firefox fallback
packages/compositor/src/browser/dnd.ts DnD bridge (NEW: native→DOM DnD implemented)
packages/compositor/src/transport/RemoteInputOutput.ts FD-over-WebSocket for native apps

Python side

Component Role
os.pipe() Create pipes (replaces Greenfield's makePipe native addon)
os.memfd_create() Create shared memory (replaces Greenfield's createMemoryMappedFile)
asyncio.StreamReader / StreamWriter Streaming reads/writes over WebSocket endpoints