react-x11 2.13.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/package.json +11 -8
  2. package/src/Reconciler.js +34 -21
  3. package/src/components/Select.js +8 -2
  4. package/src/events.js +8 -2
  5. package/src/glnodes.js +15 -3
  6. package/src/index.d.ts +5 -0
  7. package/src/nodes/boxpaint.js +9 -0
  8. package/src/nodes/preedit.js +64 -14
  9. package/src/ntk.js +10 -1
  10. package/src/scale.js +52 -22
  11. package/src/screencolor.js +104 -17
  12. package/src/types/system.d.ts +14 -1
  13. package/src/wayland/app.js +574 -0
  14. package/src/wayland/backendwindow.js +1199 -0
  15. package/src/wayland/clipboard.js +326 -0
  16. package/src/wayland/connection.js +485 -0
  17. package/src/wayland/context2d.js +2290 -0
  18. package/src/wayland/decorations.js +476 -0
  19. package/src/wayland/device.js +55 -0
  20. package/src/wayland/dmabuf.js +89 -0
  21. package/src/wayland/dnd.js +581 -0
  22. package/src/wayland/fdutil.js +108 -0
  23. package/src/wayland/framestyle.js +257 -0
  24. package/src/wayland/framewatch.js +190 -0
  25. package/src/wayland/glarea.js +372 -0
  26. package/src/wayland/glcontext.js +415 -0
  27. package/src/wayland/glyphatlas.js +237 -0
  28. package/src/wayland/input.js +417 -0
  29. package/src/wayland/keysymnames.js +35 -0
  30. package/src/wayland/layershell.js +363 -0
  31. package/src/wayland/outputs.js +601 -0
  32. package/src/wayland/protocols/cursor-shape-v1.json +1 -0
  33. package/src/wayland/protocols/ext-idle-notify-v1.json +1 -0
  34. package/src/wayland/protocols/ext-image-capture-source-v1.json +1 -0
  35. package/src/wayland/protocols/ext-image-copy-capture-v1.json +1 -0
  36. package/src/wayland/protocols/fractional-scale-v1.json +1 -0
  37. package/src/wayland/protocols/index.json +131 -0
  38. package/src/wayland/protocols/kde-server-decoration.json +1 -0
  39. package/src/wayland/protocols/keyboard-shortcuts-inhibit-unstable-v1.json +1 -0
  40. package/src/wayland/protocols/linux-dmabuf-v1.json +1 -0
  41. package/src/wayland/protocols/pointer-constraints-unstable-v1.json +1 -0
  42. package/src/wayland/protocols/presentation-time.json +1 -0
  43. package/src/wayland/protocols/primary-selection-unstable-v1.json +1 -0
  44. package/src/wayland/protocols/relative-pointer-unstable-v1.json +1 -0
  45. package/src/wayland/protocols/tablet-v2.json +1 -0
  46. package/src/wayland/protocols/text-input-unstable-v3.json +1 -0
  47. package/src/wayland/protocols/viewporter.json +1 -0
  48. package/src/wayland/protocols/wayland.json +1 -0
  49. package/src/wayland/protocols/wlr-layer-shell-unstable-v1.json +1 -0
  50. package/src/wayland/protocols/wlr-screencopy-unstable-v1.json +1 -0
  51. package/src/wayland/protocols/xdg-activation-v1.json +1 -0
  52. package/src/wayland/protocols/xdg-decoration-unstable-v1.json +1 -0
  53. package/src/wayland/protocols/xdg-output-unstable-v1.json +1 -0
  54. package/src/wayland/protocols/xdg-shell.json +1 -0
  55. package/src/wayland/protocols/xdg-toplevel-icon-v1.json +1 -0
  56. package/src/wayland/readback.js +99 -0
  57. package/src/wayland/screencopy.js +584 -0
  58. package/src/wayland/seat.js +584 -0
  59. package/src/wayland/shm.js +226 -0
  60. package/src/wayland/ssd.js +234 -0
  61. package/src/wayland/surface.js +141 -0
  62. package/src/wayland/swapchain.js +411 -0
  63. package/src/wayland/tablet.js +522 -0
  64. package/src/wayland/target.js +269 -0
  65. package/src/wayland/text.js +113 -0
  66. package/src/wayland/textinput.js +671 -0
  67. package/src/wayland/touch.js +284 -0
  68. package/src/wayland/window.js +854 -0
  69. package/src/wayland/xkb.js +425 -0
  70. package/src/windowstate.js +41 -1
@@ -0,0 +1,581 @@
1
+ // Drag and drop on the Wayland backend — the DnD half of `wl_data_device`,
2
+ // both directions, over the same device object the clipboard already binds
3
+ // (src/wayland/clipboard.js hands it out as `dataDevice`). The `dropAccept` /
4
+ // `onDrag*` / `dragData` prop contract is the tree's (src/dnd.js); what this
5
+ // file owns is the translation between Wayland's data-device vocabulary and
6
+ // that of `DropSession`/`DragSession`, in both directions — the same job
7
+ // src/cocoa/dnd.js does for AppKit.
8
+ //
9
+ // ## The drop side
10
+ //
11
+ // A drag from another application arrives as `wl_data_device.enter` /
12
+ // `motion` / `leave` / `drop`, carrying a `wl_data_offer` that lists its MIME
13
+ // types. Those four are mapped onto `DropSession.localOver` / `localLeave` /
14
+ // `localDrop`, driven with an offer flagged `source: 'external'`, so the path
15
+ // diffing, `:drag-over`, `dropAccept` matching and the handler dispatch are
16
+ // the one implementation across every backend. The answer goes straight back
17
+ // on the offer: `wl_data_offer.accept(serial, mime)`, with a null mime to
18
+ // refuse, and `set_actions`.
19
+ //
20
+ // Coordinates arrive surface-local and logical, measured from the whole
21
+ // surface including the client-side frame; the tree wants content-relative
22
+ // device pixels. One function subtracts the frame insets and multiplies by
23
+ // the scale, exactly as input.js does for the pointer — and a position over
24
+ // the titlebar or borders is outside the content, so it reads as a leave.
25
+ //
26
+ // The payload is read at the drop, eagerly, like the cocoa transport: for
27
+ // each offered type a pipe is handed to `wl_data_offer.receive` and drained
28
+ // to EOF, and `getData` then answers from that map. Only once the data is in
29
+ // hand is `wl_data_offer.finish` sent (data_device_manager v3) and the offer
30
+ // destroyed — the order the protocol requires.
31
+ //
32
+ // ## The drag source
33
+ //
34
+ // Once the renderer's threshold says a press is a drag, `DragSession._start`
35
+ // finds this backend has a session of its own (`WaylandBackendWindow`
36
+ // exposes `beginDrag`) and hands it the gesture. `beginDrag` creates a
37
+ // `wl_data_source`, offers every type, and calls `wl_data_device.start_drag`
38
+ // with the press serial. From there the compositor owns the pointer: motion
39
+ // and release stop arriving as pointer events, and the drag reports back on
40
+ // the source — `target`/`action` as it moves over accepting surfaces,
41
+ // `dnd_drop_performed`/`dnd_finished` on a completed drop, `cancelled`
42
+ // otherwise. Those end the `DragSession` the way cocoa's `_routeDragEnded`
43
+ // does. A drop on one of *our own* surfaces comes back through the data
44
+ // device's `enter`/`motion`/`drop` — the same drop side above — and is routed
45
+ // to the live `DragSession` (`app._activeDrag`) so an in-app drop keeps
46
+ // `e.items` by reference and `e.source === 'internal'`, exactly as the X11
47
+ // and cocoa transports do for a local drop.
48
+ //
49
+ // ## The drag icon
50
+ //
51
+ // `start_drag` takes an *icon surface*, not a popup: a Wayland popup cannot
52
+ // follow a drag, so the tree's `<popup dragPreview>` has no place here. The
53
+ // protocol's answer is a role-less `wl_surface` the compositor positions
54
+ // under the pointer. The wire codec cannot encode the null the protocol
55
+ // otherwise allows for "no icon", so a bare (bufferless) surface is created
56
+ // and passed instead — valid, and invisible until a buffer is attached.
57
+ // Rendering the tree's `<popup dragPreview>` into that icon surface (a
58
+ // role-less `wl_surface` the frame loop paints, in place of an xdg_popup) is
59
+ // the remaining step, left as a follow-up.
60
+ //
61
+ // ## Types
62
+ //
63
+ // Wayland types are MIME already, so there is no atom/UTI table to cross as
64
+ // there is on the other backends — only the `text/plain` beside
65
+ // `text/plain;charset=utf-8` convention the whole project keeps, so a
66
+ // `dropAccept: ['text/plain']` matches a GTK source that spells it with the
67
+ // charset, and a source that offers plain text is offered under both.
68
+
69
+ import { makePipe, readAll, writeAll, closeFd } from './fdutil.js';
70
+ import { TEXT_TARGETS, parseUriList, resolveType } from '../transfer.js';
71
+
72
+ /** `wl_data_device_manager.dnd_action` bits. */
73
+ const ACTION = { none: 0, copy: 1, move: 2, ask: 4 };
74
+ /** What a drop target says it can do: copy or move (link has no Wayland bit). */
75
+ const ACTIONS_WE_TAKE = ACTION.copy | ACTION.move;
76
+
77
+ /** The best of a `dnd_action` mask, in react-x11's words. Move wins over
78
+ * copy the way a compositor's own negotiation prefers it; `ask` and an empty
79
+ * mask fall back to copy, which is every source's least-surprising default. */
80
+ function actionName(mask) {
81
+ if (mask & ACTION.move) return 'move';
82
+ if (mask & ACTION.copy) return 'copy';
83
+ if (mask & ACTION.ask) return 'ask';
84
+ return 'copy';
85
+ }
86
+
87
+ /** One react-x11 action name as a `dnd_action` bit. `link` has no Wayland
88
+ * equivalent, so it travels as copy. */
89
+ function actionBit(name) {
90
+ if (name === 'move') return ACTION.move;
91
+ return ACTION.copy;
92
+ }
93
+
94
+ /** The action mask a source advertises for its allowed actions. */
95
+ function actionMask(names) {
96
+ let mask = 0;
97
+ for (const name of names ?? []) mask |= actionBit(name);
98
+ return mask || ACTION.copy;
99
+ }
100
+
101
+ const TEXT_ALIASES = ['text/plain;charset=utf-8', 'text/plain'];
102
+
103
+ /** Narrow: only the plain-text spellings, for the source alias convention —
104
+ * a `text/plain` source is also offered as `text/plain;charset=utf-8`, but a
105
+ * `text/uri-list` one is not. */
106
+ function isTextMime(mime) {
107
+ return (
108
+ mime === 'text/plain' ||
109
+ mime.startsWith('text/plain;') ||
110
+ TEXT_TARGETS.includes(mime)
111
+ );
112
+ }
113
+
114
+ /** Broad: every `text/*` flavour, for deciding whether received bytes decode
115
+ * to a string — the same rule transfer.js's `decodeData` applies. */
116
+ function isTextual(mime) {
117
+ return TEXT_TARGETS.includes(mime) || /^text\//i.test(mime);
118
+ }
119
+
120
+ /** What a data offer's MIME list is in react-x11's vocabulary: the types as
121
+ * offered, with `text/plain` surfaced beside a `text/plain;charset=utf-8` so
122
+ * a `dropAccept: ['text/plain']` matches the charset spelling GTK/Qt use. */
123
+ export function offeredTypes(mimes) {
124
+ const out = [];
125
+ const add = (t) => t && !out.includes(t) && out.push(t);
126
+ for (const mime of mimes ?? []) {
127
+ add(mime);
128
+ if (mime === 'text/plain;charset=utf-8') add('text/plain');
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** The MIME types a source offers for a session's payload: every declared
134
+ * type, plus both text spellings when either is present, so a plain-text
135
+ * source still satisfies a `text/plain;charset=utf-8` target. Returns a map
136
+ * from the offered MIME back to the session type that resolves it. */
137
+ export function sourceOffers(session) {
138
+ const map = {};
139
+ for (const type of session.types) {
140
+ map[type] = type;
141
+ if (isTextMime(type)) {
142
+ for (const alias of TEXT_ALIASES) if (!(alias in map)) map[alias] = type;
143
+ }
144
+ }
145
+ return map;
146
+ }
147
+
148
+ /** A value as the wire carries it: strings and bytes as they are, anything
149
+ * else as JSON — the same rule the X and cocoa transports apply. */
150
+ export function wireBytes(value) {
151
+ if (value == null) return Buffer.alloc(0);
152
+ if (Buffer.isBuffer(value)) return value;
153
+ if (ArrayBuffer.isView(value)) {
154
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
155
+ }
156
+ if (value instanceof ArrayBuffer) return Buffer.from(value);
157
+ if (typeof value === 'string') return Buffer.from(value, 'utf8');
158
+ return Buffer.from(JSON.stringify(value), 'utf8');
159
+ }
160
+
161
+ /**
162
+ * Surface-local logical coordinates → content-relative device pixels, or
163
+ * null when the point is over the client-side frame (titlebar or border),
164
+ * which is outside the content and reads as a leave. Mirrors input.js's
165
+ * `_point`, minus nothing: the frame test is the same insets the pointer
166
+ * router uses.
167
+ */
168
+ function contentPoint(win, x, y) {
169
+ const i = win.insets ?? { top: 0, left: 0, right: 0, bottom: 0 };
170
+ const s = win.scale ?? 1;
171
+ if (x < i.left || y < i.top) return null;
172
+ return { x: Math.round((x - i.left) * s), y: Math.round((y - i.top) * s) };
173
+ }
174
+
175
+ /**
176
+ * The app-wide drag-and-drop object, one per connection, created after the
177
+ * clipboard so it can share its `wl_data_device` (the DnD events arrive on
178
+ * the same object the clipboard's selection events do).
179
+ */
180
+ export class WaylandDnd {
181
+ /**
182
+ * @param {object} opts
183
+ * @param {import('./app.js').WaylandApp} opts.app
184
+ * @param {object} opts.device the shared `wl_data_device`
185
+ * @param {object} opts.manager the `wl_data_device_manager`
186
+ * @param {import('./seat.js').WaylandSeat} opts.seat
187
+ */
188
+ constructor({ app, device, manager, seat }) {
189
+ this.app = app;
190
+ this.device = device;
191
+ this.manager = manager;
192
+ this.seat = seat;
193
+ /** wl_surface id -> { window, session, node } for each drop target */
194
+ this._targets = new Map();
195
+ /** wl_data_offer id -> { offer, types, sourceActions, action, enterSerial } */
196
+ this._offers = new Map();
197
+ /** the drag currently over one of our surfaces, or null */
198
+ this._incoming = null;
199
+ /** our own outgoing drag, or null */
200
+ this._source = null;
201
+ this._wire();
202
+ }
203
+
204
+ _wire() {
205
+ const d = this.device;
206
+ // A second `data_offer` listener beside the clipboard's: both track the
207
+ // offer's types, and only the matching gesture (a `selection` for the
208
+ // clipboard, an `enter` for a drag) consumes what it collected.
209
+ d.on('data_offer', (offer) => this._trackOffer(offer));
210
+ d.on('enter', (serial, surface, x, y, offer) =>
211
+ this._onEnter(serial, surface, x, y, offer),
212
+ );
213
+ d.on('motion', (time, x, y) => this._onMotion(time, x, y));
214
+ d.on('leave', () => this._onLeave());
215
+ d.on('drop', () => this._onDrop());
216
+ }
217
+
218
+ // ---- drop targets -------------------------------------------------------
219
+
220
+ /** A window's `attachDropTransport` registers here: its surface routes to
221
+ * this DropSession. */
222
+ attach(window, session, node) {
223
+ this._targets.set(window.wl.surface.id, { window, session, node });
224
+ }
225
+
226
+ detach(window) {
227
+ this._targets.delete(window.wl.surface.id);
228
+ }
229
+
230
+ // ---- the drop side ------------------------------------------------------
231
+
232
+ _trackOffer(offer) {
233
+ const state = {
234
+ offer,
235
+ types: [],
236
+ sourceActions: 0,
237
+ action: 0,
238
+ enterSerial: 0,
239
+ };
240
+ this._offers.set(offer.id, state);
241
+ offer.on('offer', (mime) => state.types.push(mime));
242
+ offer.on('source_actions', (mask) => {
243
+ state.sourceActions = mask;
244
+ });
245
+ offer.on('action', (mask) => {
246
+ state.action = mask;
247
+ });
248
+ }
249
+
250
+ /** The live drag in this process, when it is ours that is over our own
251
+ * window — the analogue of cocoa's `ev.local`. Only ever non-null while
252
+ * this client is the drag source (`DragSession` set `app._activeDrag`). */
253
+ _localDrag() {
254
+ const drag = this.app._activeDrag;
255
+ return drag && drag._nativeSession ? drag : null;
256
+ }
257
+
258
+ _onEnter(serial, surfaceId, x, y, offerObj) {
259
+ const offerId = offerObj?.id ?? offerObj ?? 0;
260
+ const state = this._offers.get(offerId) ?? null;
261
+ const target = this._targets.get(surfaceId) ?? null;
262
+ if (state) state.enterSerial = serial; // accept/refuse quote the enter
263
+ if (!target || !state) {
264
+ // Not one of our drop-capable surfaces (or an offer we never saw the
265
+ // types of): refuse so the compositor shows no-drop, and forget it.
266
+ if (state) this._reject(state);
267
+ this._incoming = null;
268
+ return;
269
+ }
270
+ this._incoming = { target, state };
271
+ this._over(x, y, Date.now());
272
+ }
273
+
274
+ _onMotion(time, x, y) {
275
+ if (this._incoming) this._over(x, y, time >>> 0 || Date.now());
276
+ }
277
+
278
+ _over(x, y, time) {
279
+ const inc = this._incoming;
280
+ if (!inc) return;
281
+ const { target, state } = inc;
282
+ const point = contentPoint(target.window, x, y);
283
+ if (!point) {
284
+ // over the frame: outside the content, so leave the tree and refuse
285
+ target.session.localLeave();
286
+ const drag = this._localDrag();
287
+ if (drag) drag.accepted = false;
288
+ this._reject(state);
289
+ return;
290
+ }
291
+ const drag = this._localDrag();
292
+ const offer = drag
293
+ ? drag._offer()
294
+ : {
295
+ types: offeredTypes(state.types),
296
+ action: actionName(state.action || state.sourceActions),
297
+ source: 'external',
298
+ };
299
+ const answer = target.session.localOver(point.x, point.y, offer, time);
300
+ if (drag) {
301
+ drag.accepted = answer.accepted;
302
+ if (answer.accepted) drag.currentAction = answer.action;
303
+ }
304
+ if (answer.accepted) this._accept(state, answer.action);
305
+ else this._reject(state);
306
+ }
307
+
308
+ _onLeave() {
309
+ const inc = this._incoming;
310
+ this._incoming = null;
311
+ if (!inc) return;
312
+ inc.target.session.localLeave();
313
+ const drag = this._localDrag();
314
+ if (drag) drag.accepted = false;
315
+ this._destroyOffer(inc.state);
316
+ }
317
+
318
+ _onDrop() {
319
+ const inc = this._incoming;
320
+ this._incoming = null;
321
+ if (!inc) return;
322
+ const { target, state } = inc;
323
+ const drag = this._localDrag();
324
+ if (drag) {
325
+ // in-app: the live payload by reference, no pipes and no receive
326
+ const outcome = target.session.localDrop(
327
+ drag._offer(),
328
+ drag._dropExtras(),
329
+ Date.now(),
330
+ );
331
+ if (outcome.handled) {
332
+ drag.currentAction = outcome.action;
333
+ this._finishOffer(state, outcome.action);
334
+ } else {
335
+ this._destroyOffer(state);
336
+ }
337
+ return;
338
+ }
339
+ void this._externalDrop(target, state);
340
+ }
341
+
342
+ /**
343
+ * Read every offered type over a pipe, drain each to EOF, then dispatch the
344
+ * drop with the assembled payload and, only if a handler took it, finish
345
+ * the offer. Reading eagerly (rather than lazily behind `getData`) is what
346
+ * lets `finish` be sent — the protocol requires the data to be in hand
347
+ * first, and the offer is gone the moment it is.
348
+ */
349
+ async _externalDrop(target, state) {
350
+ const types = offeredTypes(state.types);
351
+ const values = {};
352
+ for (const mime of state.types) {
353
+ try {
354
+ const { read, write } = makePipe();
355
+ state.offer.$.receive(mime, write);
356
+ const bytes = await readAll(read);
357
+ values[mime] = isTextual(mime) ? bytes.toString('utf8') : bytes;
358
+ if (
359
+ mime === 'text/plain;charset=utf-8' &&
360
+ values['text/plain'] === undefined
361
+ ) {
362
+ values['text/plain'] = values[mime];
363
+ }
364
+ } catch {
365
+ // a type the source could not actually convert to; skip it
366
+ }
367
+ }
368
+ const files = values['text/uri-list']
369
+ ? parseUriList(values['text/uri-list'])
370
+ : [];
371
+ const best = TEXT_TARGETS.find((t) => values[t] !== undefined);
372
+ const extras = {
373
+ items: values,
374
+ files,
375
+ text: best ? values[best] : undefined,
376
+ getData: (type) =>
377
+ Promise.resolve(values[resolveType(type, types)] ?? null),
378
+ };
379
+ const action = actionName(state.action || state.sourceActions);
380
+ const outcome = target.session.localDrop(
381
+ { types, action, source: 'external' },
382
+ extras,
383
+ Date.now(),
384
+ );
385
+ if (outcome.handled) this._finishOffer(state, outcome.action);
386
+ else this._destroyOffer(state);
387
+ }
388
+
389
+ /** Tell the source we take the drag, and how. */
390
+ _accept(state, action) {
391
+ this._setActions(state, action);
392
+ this._offerAccept(state, state.types[0] ?? null);
393
+ }
394
+
395
+ /**
396
+ * Tell the source we do not: no action, and a null mime, which names no
397
+ * type, where "" would name an empty one.
398
+ */
399
+ _reject(state) {
400
+ this._setActions(state, null);
401
+ this._offerAccept(state, null);
402
+ }
403
+
404
+ _setActions(state, action) {
405
+ if (typeof state.offer.$.set_actions !== 'function') return;
406
+ try {
407
+ if (!action) state.offer.$.set_actions(0, ACTION.none);
408
+ else state.offer.$.set_actions(ACTIONS_WE_TAKE, actionBit(action));
409
+ } catch {
410
+ /* the offer may be gone */
411
+ }
412
+ }
413
+
414
+ _offerAccept(state, mime) {
415
+ try {
416
+ state.offer.$.accept(state.enterSerial, mime);
417
+ } catch {
418
+ /* the offer may be gone */
419
+ }
420
+ }
421
+
422
+ _finishOffer(state, action) {
423
+ this._setActions(state, action);
424
+ this._offerAccept(state, state.types[0] ?? null);
425
+ try {
426
+ if (typeof state.offer.$.finish === 'function') state.offer.$.finish();
427
+ } catch {
428
+ /* v2 offer, or already gone */
429
+ }
430
+ this._destroyOffer(state);
431
+ }
432
+
433
+ _destroyOffer(state) {
434
+ this._offers.delete(state.offer.id);
435
+ try {
436
+ state.offer.$.destroy();
437
+ } catch {
438
+ /* already gone */
439
+ }
440
+ }
441
+
442
+ // ---- the drag source ----------------------------------------------------
443
+
444
+ /**
445
+ * Hand a `DragSession`'s gesture to a `wl_data_source`. Called by
446
+ * `DragSession._start` through `WaylandBackendWindow.beginDrag`; returns
447
+ * truthy once the drag is under way, null when there is no data device to
448
+ * start it on.
449
+ */
450
+ beginDrag(window, session) {
451
+ if (!this.manager || !this.app.compositor) return null;
452
+ const source = this.manager.$.create_data_source();
453
+ const offerMap = sourceOffers(session);
454
+ for (const mime of Object.keys(offerMap)) source.$.offer(mime);
455
+ if (typeof source.$.set_actions === 'function') {
456
+ source.$.set_actions(actionMask(session.actions));
457
+ }
458
+ source.on('send', (mime, fd) => this._send(session, offerMap, mime, fd));
459
+ source.on('target', (mime) => this._onTarget(session, mime));
460
+ source.on('action', (mask) => this._onAction(session, mask));
461
+ source.on('dnd_drop_performed', () => {
462
+ if (this._source) this._source.performed = true;
463
+ });
464
+ source.on('dnd_finished', () => this._endSource(session, true));
465
+ source.on('cancelled', () => this._endSource(session, false));
466
+
467
+ // The icon is a role-less surface the compositor puts under the pointer.
468
+ // Bare (no buffer) here — a valid "no visible icon", and the only way to
469
+ // say it, since the wire codec cannot encode the null the protocol
470
+ // reserves for it. Rendering the preview into it is a follow-up.
471
+ let icon = null;
472
+ try {
473
+ icon = this.app.compositor.$.create_surface();
474
+ } catch {
475
+ icon = null;
476
+ }
477
+ if (!icon) return null;
478
+
479
+ const serial = this.seat.lastPressSerial || this.seat.lastSerial || 0;
480
+ this._source = {
481
+ session,
482
+ source,
483
+ offerMap,
484
+ icon,
485
+ window,
486
+ action: session.currentAction,
487
+ performed: false,
488
+ ended: false,
489
+ };
490
+ try {
491
+ this.device.$.start_drag(
492
+ source.id,
493
+ window.wl.surface.id,
494
+ icon.id,
495
+ serial,
496
+ );
497
+ } catch (err) {
498
+ this._teardownSource();
499
+ throw err;
500
+ }
501
+ return true;
502
+ }
503
+
504
+ _send(session, offerMap, mime, fd) {
505
+ if (typeof fd !== 'number' || fd < 0) return;
506
+ try {
507
+ const type = offerMap[mime] ?? mime;
508
+ const bytes = wireBytes(session._resolve(type));
509
+ writeAll(fd, bytes).catch(() => closeFd(fd));
510
+ } catch {
511
+ closeFd(fd);
512
+ }
513
+ }
514
+
515
+ /** The source's own view of the gesture: the current target accepts (a
516
+ * non-empty mime) or not (empty, the wire's stand-in for null). Feeds
517
+ * `onDrag` with the updated accept state. */
518
+ _onTarget(session, mime) {
519
+ session.accepted = Boolean(mime);
520
+ session.nativeMoved?.(this._sourcePos(session));
521
+ }
522
+
523
+ _onAction(session, mask) {
524
+ const name = actionName(mask);
525
+ if (this._source) this._source.action = name;
526
+ session.currentAction = name;
527
+ session.nativeMoved?.(this._sourcePos(session));
528
+ }
529
+
530
+ /**
531
+ * The gesture is over. `dropped` is whether it landed on a target
532
+ * (`dnd_finished`) or was cancelled — the same shape `nativeEnded` reads on
533
+ * every backend, so `onDragEnd` and a `move`'s source deletion follow the
534
+ * one path.
535
+ */
536
+ _endSource(session, dropped) {
537
+ const s = this._source;
538
+ if (!s || s.session !== session || s.ended) return;
539
+ s.ended = true;
540
+ const pos = this._sourcePos(session);
541
+ try {
542
+ session.nativeEnded?.({
543
+ x: pos.x,
544
+ y: pos.y,
545
+ operation: dropped ? s.action : null,
546
+ dropped,
547
+ });
548
+ } finally {
549
+ this._teardownSource();
550
+ }
551
+ }
552
+
553
+ /** A best-effort pointer position for the source's `onDrag`/`onDragEnd`.
554
+ * A Wayland drag delivers no global motion to the source, so this is the
555
+ * press point in logical coordinates — the preview that would follow it is
556
+ * the compositor's icon surface here, not a popup. */
557
+ _sourcePos(session) {
558
+ const scale = this._source?.window?.scale ?? this.app.scale ?? 1;
559
+ const p = session.press ?? {};
560
+ return {
561
+ x: (p.rootx ?? p.x ?? 0) / scale,
562
+ y: (p.rooty ?? p.y ?? 0) / scale,
563
+ };
564
+ }
565
+
566
+ _teardownSource() {
567
+ const s = this._source;
568
+ if (!s) return;
569
+ this._source = null;
570
+ try {
571
+ s.icon?.$.destroy();
572
+ } catch {
573
+ /* already gone */
574
+ }
575
+ try {
576
+ s.source.$.destroy();
577
+ } catch {
578
+ /* already gone */
579
+ }
580
+ }
581
+ }
@@ -0,0 +1,108 @@
1
+ // The two descriptor primitives Wayland data transfer needs and JavaScript
2
+ // runtimes do not expose: a pipe, and reading/writing a raw fd as a stream.
3
+ //
4
+ // A clipboard paste on Wayland is: make a pipe, hand the write end to the
5
+ // compositor (`wl_data_offer.receive`), read the content from the read end.
6
+ // A copy is the reverse: the compositor hands us a write end and we stream
7
+ // into it. Neither Node nor Bun has `pipe(2)`. x11-dri grew one for exactly
8
+ // this; on Bun without it, `bun:ffi` reaches libc directly.
9
+
10
+ import fs from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+
15
+ let bunPipe = null;
16
+
17
+ /**
18
+ * @returns {{ read: number, write: number }} both ends, close-on-exec
19
+ */
20
+ export function makePipe() {
21
+ try {
22
+ const dri = require('x11-dri');
23
+ if (typeof dri.pipe === 'function') return dri.pipe();
24
+ } catch {
25
+ /* fall through */
26
+ }
27
+ if (typeof Bun !== 'undefined') {
28
+ if (!bunPipe) bunPipe = loadBunPipe();
29
+ if (bunPipe) return bunPipe();
30
+ }
31
+ throw new Error(
32
+ 'no way to create a pipe: install x11-dri >= 0.9, or run under Bun',
33
+ );
34
+ }
35
+
36
+ function loadBunPipe() {
37
+ try {
38
+ const { dlopen, FFIType, ptr } = require('bun:ffi');
39
+ const names = ['libc.so.6', 'libc.so', 'libSystem.B.dylib'];
40
+ let lib = null;
41
+ for (const n of names) {
42
+ try {
43
+ lib = dlopen(n, {
44
+ pipe2: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
45
+ });
46
+ break;
47
+ } catch {
48
+ /* next */
49
+ }
50
+ }
51
+ if (!lib) return null;
52
+ const O_CLOEXEC = 0x80000;
53
+ return () => {
54
+ const fds = new Int32Array(2);
55
+ if (lib.symbols.pipe2(ptr(fds), O_CLOEXEC) !== 0)
56
+ throw new Error('pipe2 failed');
57
+ return { read: fds[0], write: fds[1] };
58
+ };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Read everything from a descriptor until EOF, then close it. Resolves with
66
+ * a Buffer. The compositor (or the source client) writes at its own pace, so
67
+ * this is a stream, not a blocking read.
68
+ */
69
+ export function readAll(fd, { timeout = 5000 } = {}) {
70
+ return new Promise((resolve, reject) => {
71
+ const chunks = [];
72
+ const stream = fs.createReadStream('', { fd, autoClose: true });
73
+ const timer = setTimeout(() => {
74
+ stream.destroy();
75
+ reject(new Error('timed out waiting for the selection content'));
76
+ }, timeout);
77
+ stream.on('data', (c) => chunks.push(c));
78
+ stream.on('end', () => {
79
+ clearTimeout(timer);
80
+ resolve(Buffer.concat(chunks));
81
+ });
82
+ stream.on('error', (err) => {
83
+ clearTimeout(timer);
84
+ reject(err);
85
+ });
86
+ });
87
+ }
88
+
89
+ /** Write a buffer to a descriptor and close it. */
90
+ export function writeAll(fd, data) {
91
+ return new Promise((resolve, reject) => {
92
+ const stream = fs.createWriteStream('', { fd, autoClose: true });
93
+ stream.on('error', (err) => {
94
+ // EPIPE: the reader went away; that is their business, not an error here
95
+ if (err?.code === 'EPIPE') resolve();
96
+ else reject(err);
97
+ });
98
+ stream.end(data, () => resolve());
99
+ });
100
+ }
101
+
102
+ export function closeFd(fd) {
103
+ try {
104
+ fs.closeSync(fd);
105
+ } catch {
106
+ /* already closed */
107
+ }
108
+ }