react-x11 2.13.0 → 2.14.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.
- package/package.json +11 -8
- package/src/Reconciler.js +34 -21
- package/src/components/Select.js +8 -2
- package/src/events.js +8 -2
- package/src/index.d.ts +5 -0
- package/src/nodes/boxpaint.js +9 -0
- package/src/nodes/preedit.js +64 -14
- package/src/scale.js +52 -22
- package/src/screencolor.js +104 -17
- package/src/wayland/app.js +560 -0
- package/src/wayland/backendwindow.js +1123 -0
- package/src/wayland/clipboard.js +326 -0
- package/src/wayland/connection.js +482 -0
- package/src/wayland/context2d.js +2133 -0
- package/src/wayland/decorations.js +476 -0
- package/src/wayland/dmabuf.js +89 -0
- package/src/wayland/dnd.js +581 -0
- package/src/wayland/fdutil.js +108 -0
- package/src/wayland/framestyle.js +257 -0
- package/src/wayland/glarea.js +371 -0
- package/src/wayland/glcontext.js +415 -0
- package/src/wayland/glyphatlas.js +237 -0
- package/src/wayland/input.js +417 -0
- package/src/wayland/keysymnames.js +35 -0
- package/src/wayland/layershell.js +363 -0
- package/src/wayland/outputs.js +601 -0
- package/src/wayland/protocols/cursor-shape-v1.json +1 -0
- package/src/wayland/protocols/ext-idle-notify-v1.json +1 -0
- package/src/wayland/protocols/ext-image-capture-source-v1.json +1 -0
- package/src/wayland/protocols/ext-image-copy-capture-v1.json +1 -0
- package/src/wayland/protocols/fractional-scale-v1.json +1 -0
- package/src/wayland/protocols/index.json +127 -0
- package/src/wayland/protocols/keyboard-shortcuts-inhibit-unstable-v1.json +1 -0
- package/src/wayland/protocols/linux-dmabuf-v1.json +1 -0
- package/src/wayland/protocols/pointer-constraints-unstable-v1.json +1 -0
- package/src/wayland/protocols/presentation-time.json +1 -0
- package/src/wayland/protocols/primary-selection-unstable-v1.json +1 -0
- package/src/wayland/protocols/relative-pointer-unstable-v1.json +1 -0
- package/src/wayland/protocols/tablet-v2.json +1 -0
- package/src/wayland/protocols/text-input-unstable-v3.json +1 -0
- package/src/wayland/protocols/viewporter.json +1 -0
- package/src/wayland/protocols/wayland.json +1 -0
- package/src/wayland/protocols/wlr-layer-shell-unstable-v1.json +1 -0
- package/src/wayland/protocols/wlr-screencopy-unstable-v1.json +1 -0
- package/src/wayland/protocols/xdg-activation-v1.json +1 -0
- package/src/wayland/protocols/xdg-decoration-unstable-v1.json +1 -0
- package/src/wayland/protocols/xdg-output-unstable-v1.json +1 -0
- package/src/wayland/protocols/xdg-shell.json +1 -0
- package/src/wayland/protocols/xdg-toplevel-icon-v1.json +1 -0
- package/src/wayland/readback.js +99 -0
- package/src/wayland/screencopy.js +584 -0
- package/src/wayland/seat.js +584 -0
- package/src/wayland/shm.js +226 -0
- package/src/wayland/ssd.js +106 -0
- package/src/wayland/surface.js +123 -0
- package/src/wayland/swapchain.js +411 -0
- package/src/wayland/tablet.js +522 -0
- package/src/wayland/target.js +263 -0
- package/src/wayland/text.js +113 -0
- package/src/wayland/textinput.js +671 -0
- package/src/wayland/touch.js +284 -0
- package/src/wayland/window.js +827 -0
- package/src/wayland/xkb.js +425 -0
|
@@ -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
|
+
}
|