ntk 5.2.0 → 5.3.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/lib/clipboard.js CHANGED
@@ -2,7 +2,7 @@ import x11 from 'x11';
2
2
 
3
3
  import { safeRelease } from './cleanup.js';
4
4
 
5
- // Clipboard (app.clipboard): ICCCM selection transfer for plain text.
5
+ // Clipboard (app.clipboard): ICCCM selection transfer.
6
6
  //
7
7
  // X has no clipboard buffer — "copy" means owning a selection atom
8
8
  // (CLIPBOARD for explicit copy/paste, PRIMARY for middle-click paste) and
@@ -11,54 +11,100 @@ import { safeRelease } from './cleanup.js';
11
11
  // module hides that dance behind write()/read() promises, using a hidden
12
12
  // 1x1 never-mapped helper window as the selection endpoint.
13
13
  //
14
- // Supported targets when ntk owns a selection: TARGETS, UTF8_STRING and
15
- // STRING (latin-1, best effort). Everything else — MULTIPLE, TIMESTAMP,
16
- // images — is refused with SelectionNotify property None per ICCCM.
14
+ // As an owner, ntk answers the three targets ICCCM 2.6.2 makes mandatory
15
+ // (TARGETS, TIMESTAMP, MULTIPLE) plus whatever the caller offered: a string
16
+ // is served as UTF8_STRING and STRING (latin-1, best effort), an object maps
17
+ // target names to payloads, so HTML, images and — later — XDND drags are the
18
+ // same machinery. Payloads larger than one request are transferred
19
+ // incrementally (INCR, ICCCM 2.7.2), in both directions.
17
20
  //
18
21
  // Reads prefer UTF8_STRING and retry once with STRING when the owner
19
- // refuses (old Xt/Motif apps). Incremental (INCR) transfers are supported
20
- // on the read side, so pasting more than the server's transfer limit works.
21
- //
22
- // LIMITATION: INCR is NOT implemented on the write side — write() hands the
23
- // whole payload to the server in a single ChangeProperty when a requestor
24
- // converts. Texts approaching the server's maximum request length (~256KB
25
- // on servers without BIG-REQUESTS, node-x11 does not negotiate it) may fail
26
- // to paste into other applications.
22
+ // refuses (old Xt/Motif apps).
27
23
 
28
24
  const DEFAULT_TIMEOUT = 2000;
25
+ // An INCR transfer we are feeding is driven entirely by the requestor: each
26
+ // chunk goes out in answer to its property deletion. A requestor that stops
27
+ // (or dies mid-paste) would otherwise leave us holding its payload and an
28
+ // event mask on its window forever, so give up after this much silence.
29
+ const INCR_TIMEOUT = 10000;
30
+ // node-x11 writes ChangeProperty's length field as a CARD16 — only PutImage
31
+ // emits the BIG-REQUESTS extended form — so a request can never exceed
32
+ // 65535 four-byte units however much the server allows.
33
+ const REQUEST_UNITS_LIMIT = 0xffff;
34
+
35
+ const isBinary = (value) => ArrayBuffer.isView(value) || value instanceof ArrayBuffer;
36
+
37
+ // payload bytes for one target: text is UTF-8 (latin-1 for STRING, which is
38
+ // defined as latin-1), binary is taken as-is
39
+ const encodePayload = (value, name) => {
40
+ if (typeof value === 'string') return Buffer.from(value, name === 'STRING' ? 'latin1' : 'utf8');
41
+ if (Buffer.isBuffer(value)) return value;
42
+ if (ArrayBuffer.isView(value))
43
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
44
+ if (value instanceof ArrayBuffer) return Buffer.from(value);
45
+ throw new TypeError(`clipboard: the ${name} payload must be a string or binary data`);
46
+ };
29
47
 
30
48
  export default class Clipboard {
31
49
  constructor(app) {
32
50
  this.app = app;
33
51
  this.X = app.X;
34
52
  this._window = null; // hidden helper window, created on first use
35
- this._owned = new Map(); // selection atom -> text we serve
36
- this._atoms = null; // { TARGETS, UTF8_STRING, INCR }
53
+ this._owned = new Map(); // selection atom -> { time, targets }
54
+ this._atoms = null; // { TARGETS, TIMESTAMP, MULTIPLE, ATOM_PAIR, UTF8_STRING, INCR }
37
55
  this._transferProp = null; // property reads are converted into
56
+ this._timeProp = null; // property the server-timestamp trick appends to
57
+ this._transfers = new Map(); // `requestor:property` -> INCR transfer we feed
58
+ this._watched = new Map(); // requestor wid -> { count, mask } while feeding
59
+ this._transferLimit = null; // bytes one ChangeProperty can carry
60
+ this._incrTimeout = INCR_TIMEOUT;
38
61
  this._ready = null;
39
62
  this._readQueue = Promise.resolve();
63
+ this._fixes = null; // XFixes extension, required on first watch()
64
+ this._fixesFirstEvent = -1; // its event base, to recognise its events
65
+ this._selectionWatchers = new Map(); // selection atom -> { name, handlers }
40
66
  this._onXEvent = this._onXEvent.bind(this);
41
67
  }
42
68
 
43
69
  /**
44
- * Take ownership of a selection and serve `text` to anyone who pastes.
70
+ * Take ownership of a selection and serve `data` to anyone who pastes.
45
71
  * Resolves once the server confirms the ownership; ownership (and the
46
- * text) is held until another client copies or the app closes.
72
+ * data) is held until another client copies or the app closes.
73
+ *
74
+ * `data` is either a string — offered as UTF8_STRING and STRING — or an
75
+ * object (or Map) from target name to payload, which is how richer
76
+ * formats are published:
77
+ *
78
+ * await app.clipboard.write({
79
+ * 'text/plain;charset=utf-8': 'hello',
80
+ * 'text/html': '<b>hello</b>',
81
+ * 'image/png': pngBuffer
82
+ * });
47
83
  *
48
- * @param {string} text
84
+ * String payloads are encoded UTF-8 (latin-1 for the STRING target);
85
+ * Buffers/TypedArrays are served as-is. TARGETS, TIMESTAMP and MULTIPLE
86
+ * are answered by ntk itself and cannot be offered as data.
87
+ *
88
+ * @param {string|object|Map} data
49
89
  * @param {object} [options] { selection: 'CLIPBOARD' (default) or
50
- * 'PRIMARY' (middle-click paste) — any selection atom name works }
90
+ * 'PRIMARY' (middle-click paste) — any selection atom name works;
91
+ * time: the server timestamp of the event that triggered the copy
92
+ * (ICCCM 2.1). Without one ntk asks the server for the current time
93
+ * rather than using CurrentTime, which ICCCM forbids }
51
94
  * @returns {Promise<void>}
52
95
  */
53
- async write(text, { selection = 'CLIPBOARD' } = {}) {
96
+ async write(data, { selection = 'CLIPBOARD', time } = {}) {
54
97
  await this._ensure();
55
- const sel = await this._atom(selection);
56
- this._owned.set(sel, String(text));
57
- // time 0 = CurrentTime: best effort — ntk has no "last user input"
58
- // timestamp to arbitrate ownership races with (ICCCM prefers one)
59
- this.X.SetSelectionOwner(this._window.id, sel, 0);
98
+ const [sel, targets] = await Promise.all([this._atom(selection), this._encode(data)]);
99
+ // ICCCM 2.1: never CurrentTime. The timestamp of the event that
100
+ // triggered the copy is the right one — it is what arbitrates a race
101
+ // with another app copying at the same moment. Without one, "now" is
102
+ // still a real timestamp, and one no server will reject as stale.
103
+ const stamp = time === undefined ? await this._serverTime() : time >>> 0;
104
+ this._owned.set(sel, { time: stamp, targets });
105
+ this.X.SetSelectionOwner(this._window.id, sel, stamp);
60
106
  // SetSelectionOwner is void: confirm via GetSelectionOwner that the
61
- // server actually made us the owner
107
+ // server actually made us the owner (it ignores a stale timestamp)
62
108
  const owner = await new Promise((resolve, reject) =>
63
109
  this.X.GetSelectionOwner(sel, (err, wid) => (err ? reject(err) : resolve(wid)))
64
110
  );
@@ -78,15 +124,86 @@ export default class Clipboard {
78
124
  * timeout: ms to wait for the owner at each protocol step }
79
125
  * @returns {Promise<string>}
80
126
  */
81
- read({ selection = 'CLIPBOARD', timeout = DEFAULT_TIMEOUT } = {}) {
82
- // serialize: concurrent reads would share the one transfer property on
83
- // the helper window, so let each conversion finish before the next
84
- const run = () => this._read(selection, timeout);
127
+ read({ selection = 'CLIPBOARD', timeout = DEFAULT_TIMEOUT, target } = {}) {
128
+ return this._serialize(() =>
129
+ target === undefined
130
+ ? this._read(selection, timeout)
131
+ : this._readTarget(selection, target, timeout)
132
+ );
133
+ }
134
+
135
+ /**
136
+ * What the current owner of a selection can convert to, as target names.
137
+ *
138
+ * const offered = await app.clipboard.targets();
139
+ * if (offered.includes('image/png')) { ... }
140
+ *
141
+ * This is the question to ask before `read({ target })`: an owner answers
142
+ * `TARGETS` cheaply, where guessing costs a failed conversion per guess.
143
+ * Returns `[]` when nothing owns the selection.
144
+ *
145
+ * @param {object} [options] { selection, timeout }
146
+ * @returns {Promise<string[]>}
147
+ */
148
+ targets({ selection = 'CLIPBOARD', timeout = DEFAULT_TIMEOUT } = {}) {
149
+ return this._serialize(() => this._targets(selection, timeout));
150
+ }
151
+
152
+ /** Concurrent conversions would share the one transfer property on the
153
+ * helper window, so let each finish before the next starts. */
154
+ _serialize(run) {
85
155
  const result = this._readQueue.then(run, run);
86
156
  this._readQueue = result.catch(() => {});
87
157
  return result;
88
158
  }
89
159
 
160
+ async _targets(selection, timeout) {
161
+ await this._ensure();
162
+ const sel = await this._atom(selection);
163
+ const prop = this._transferProp;
164
+ const notify = await this._convert(sel, this._atoms.TARGETS, prop, selection, timeout);
165
+ if (notify.property === 0) return [];
166
+ const { data, format } = await this._fetchProperty(prop, selection, timeout);
167
+ // an INCR reassembly reports no format, and a target list is far too
168
+ // small to arrive that way — so only a stated non-32 format disqualifies
169
+ if (format !== undefined && format !== 32) return [];
170
+ const atoms = [];
171
+ for (let o = 0; o + 4 <= data.length; o += 4) atoms.push(data.readUInt32LE(o));
172
+ const names = await Promise.all(atoms.map((atom) => this._atomName(atom)));
173
+ return names.filter(Boolean);
174
+ }
175
+
176
+ /**
177
+ * Read one named target, as bytes. The mirror of writing one: what
178
+ * `write({ 'image/png': buf })` publishes, this is how another ntk app
179
+ * gets it back.
180
+ */
181
+ async _readTarget(selection, target, timeout) {
182
+ await this._ensure();
183
+ const sel = await this._atom(selection);
184
+ const atom = await this._atom(target);
185
+ const prop = this._transferProp;
186
+ const notify = await this._convert(sel, atom, prop, selection, timeout);
187
+ if (notify.property === 0) {
188
+ const owner = await new Promise((resolve, reject) =>
189
+ this.X.GetSelectionOwner(sel, (err, wid) => (err ? reject(err) : resolve(wid)))
190
+ );
191
+ throw new Error(
192
+ owner
193
+ ? `clipboard: ${selection} selection owner cannot convert to ${target}`
194
+ : `clipboard: nothing to paste — ${selection} selection has no owner`
195
+ );
196
+ }
197
+ const { data } = await this._fetchProperty(prop, selection, timeout);
198
+ return data;
199
+ }
200
+
201
+ _atomName(atom) {
202
+ return new Promise((resolve) =>
203
+ this.X.GetAtomName(atom, (err, name) => resolve(err ? null : name))
204
+ );
205
+ }
206
+
90
207
  async _read(selection, timeout) {
91
208
  await this._ensure();
92
209
  const X = this.X;
@@ -126,23 +243,143 @@ export default class Clipboard {
126
243
  height: 1,
127
244
  eventMask: x11.eventMask.PropertyChange
128
245
  });
129
- const [TARGETS, UTF8_STRING, INCR, transferProp] = await Promise.all([
130
- this._atom('TARGETS'),
131
- this._atom('UTF8_STRING'),
132
- this._atom('INCR'),
133
- this._atom('NTK_SELECTION')
134
- ]);
135
- this._atoms = { TARGETS, UTF8_STRING, INCR };
246
+ const [TARGETS, TIMESTAMP, MULTIPLE, ATOM_PAIR, UTF8_STRING, INCR, transferProp, timeProp] =
247
+ await Promise.all(
248
+ [
249
+ 'TARGETS',
250
+ 'TIMESTAMP',
251
+ 'MULTIPLE',
252
+ 'ATOM_PAIR',
253
+ 'UTF8_STRING',
254
+ 'INCR',
255
+ 'NTK_SELECTION',
256
+ 'NTK_TIME'
257
+ ].map((name) => this._atom(name))
258
+ );
259
+ this._atoms = { TARGETS, TIMESTAMP, MULTIPLE, ATOM_PAIR, UTF8_STRING, INCR };
136
260
  this._transferProp = transferProp;
261
+ this._timeProp = timeProp;
137
262
  // SelectionRequest/SelectionClear carry the owner in ev.owner, not
138
263
  // ev.wid, so node-x11's event_consumers routing (keyed on ev.wid)
139
264
  // never delivers them to the Window wrapper — listen on the raw
140
- // client instead
265
+ // client instead. INCR chunk pacing needs PropertyNotify for the
266
+ // requestor's window, which is not ours either.
141
267
  this.X.on('event', this._onXEvent);
142
268
  })();
143
269
  return this._ready;
144
270
  }
145
271
 
272
+ /**
273
+ * Call `handler` whenever a selection changes hands.
274
+ *
275
+ * const unwatch = await app.clipboard.watch('CLIPBOARD', (ev) => {
276
+ * pasteItem.disabled = ev.owner === 0;
277
+ * });
278
+ * unwatch();
279
+ *
280
+ * The alternative is polling `read()`, which is a full conversion round
281
+ * trip against whatever foreign client owns the selection — and a two
282
+ * second wait when that client is wedged. This is a server-side
283
+ * subscription instead: the server tells us, and it costs nothing until
284
+ * something actually changes.
285
+ *
286
+ * `ev` is `{ selection, owner, timestamp, selectionTimestamp, reason }`,
287
+ * where `reason` is `'new-owner'` when someone took the selection,
288
+ * `'destroyed'` when the owning window went away, and `'closed'` when the
289
+ * owning client disconnected. `owner` is 0 when the selection ends up
290
+ * unowned, which is the case an edit menu wants: nothing to paste.
291
+ *
292
+ * Watchers share one server-side registration per selection, so watching
293
+ * the same selection twice costs one extra callback and no extra protocol.
294
+ *
295
+ * @param {string} selection selection atom name, e.g. 'CLIPBOARD' or 'PRIMARY'
296
+ * @param {function} handler called with the event
297
+ * @returns {Promise<function>} call it to stop watching
298
+ */
299
+ async watch(selection, handler) {
300
+ if (typeof handler !== 'function') {
301
+ throw new TypeError('clipboard: watch needs a handler function');
302
+ }
303
+ await this._ensure();
304
+ const fixes = await this._ensureFixes();
305
+ const sel = await this._atom(selection);
306
+
307
+ let entry = this._selectionWatchers.get(sel);
308
+ if (!entry) {
309
+ entry = { name: selection, handlers: new Set() };
310
+ this._selectionWatchers.set(sel, entry);
311
+ const mask =
312
+ fixes.SelectionEventMask.SetSelectionOwner |
313
+ fixes.SelectionEventMask.SelectionWindowDestroy |
314
+ fixes.SelectionEventMask.SelectionClientClose;
315
+ safeRelease(this.X, () => fixes.SelectSelectionInput(this._window.id, sel, mask));
316
+ }
317
+ entry.handlers.add(handler);
318
+
319
+ let stopped = false;
320
+ return () => {
321
+ if (stopped) return;
322
+ stopped = true;
323
+ const current = this._selectionWatchers.get(sel);
324
+ if (!current) return;
325
+ current.handlers.delete(handler);
326
+ if (current.handlers.size) return;
327
+ // last watcher for this selection: drop the server-side registration
328
+ this._selectionWatchers.delete(sel);
329
+ safeRelease(this.X, () => fixes.SelectSelectionInput(this._window.id, sel, 0));
330
+ };
331
+ }
332
+
333
+ /** XFixes, required once. Rejects with something readable on a server
334
+ * without it — every X server since about 2004 has had it, so this is a
335
+ * "your server is unusual" error rather than a routine fallback. */
336
+ _ensureFixes() {
337
+ if (this._fixes) return this._fixes;
338
+ this._fixes = new Promise((resolve, reject) => {
339
+ this.X.require('fixes', (err, fixes) => {
340
+ if (err || !fixes) {
341
+ this._fixes = null; // let a later call try again
342
+ return reject(
343
+ new Error(
344
+ 'clipboard: this X server has no XFixes extension, so selection ' +
345
+ `changes cannot be watched${err ? `: ${err.message}` : ''}`
346
+ )
347
+ );
348
+ }
349
+ this._fixesFirstEvent = fixes.firstEvent;
350
+ resolve(fixes);
351
+ });
352
+ });
353
+ return this._fixes;
354
+ }
355
+
356
+ /** An XFixes SelectionNotify: translate the subtype and fan out. */
357
+ _onSelectionChange(ev, fixes) {
358
+ const entry = this._selectionWatchers.get(ev.selection);
359
+ if (!entry) return;
360
+ const reason =
361
+ ev.subtype === fixes.SelectionEvent.SelectionWindowDestroy
362
+ ? 'destroyed'
363
+ : ev.subtype === fixes.SelectionEvent.SelectionClientClose
364
+ ? 'closed'
365
+ : 'new-owner';
366
+ const detail = {
367
+ selection: entry.name,
368
+ owner: ev.owner,
369
+ timestamp: ev.timestamp,
370
+ selectionTimestamp: ev.selectionTimestamp,
371
+ reason
372
+ };
373
+ // a throwing handler must not cost the others their event
374
+ for (const handler of [...entry.handlers]) {
375
+ try {
376
+ handler(detail);
377
+ } catch (err) {
378
+ console.warn(`ntk: clipboard watch handler threw: ${err.message}`);
379
+ }
380
+ }
381
+ }
382
+
146
383
  _atom(name) {
147
384
  // predefined atoms (PRIMARY = 1, STRING = 31, ...) resolve without a
148
385
  // round-trip; node-x11 caches interned ones after the first reply
@@ -151,64 +388,329 @@ export default class Clipboard {
151
388
  );
152
389
  }
153
390
 
391
+ // every server call from an event handler runs inside the packet parser's
392
+ // emit: if the connection is closing, drop it instead of throwing
393
+ _send(fn) {
394
+ safeRelease(this.X, fn);
395
+ }
396
+
397
+ // name -> payload map for one selection, target names resolved to atoms
398
+ async _encode(data) {
399
+ const a = this._atoms;
400
+ if (data === null || typeof data !== 'object') {
401
+ const text = String(data);
402
+ const STRING = this.X.atoms.STRING;
403
+ return new Map([
404
+ [a.UTF8_STRING, { type: a.UTF8_STRING, format: 8, data: Buffer.from(text, 'utf8') }],
405
+ [STRING, { type: STRING, format: 8, data: Buffer.from(text, 'latin1') }]
406
+ ]);
407
+ }
408
+ if (isBinary(data)) {
409
+ throw new TypeError(
410
+ "clipboard: binary data needs a target name — write({ 'image/png': buffer })"
411
+ );
412
+ }
413
+ const entries = data instanceof Map ? [...data] : Object.entries(data);
414
+ if (!entries.length) throw new TypeError('clipboard: write() needs at least one target');
415
+ const atoms = await Promise.all(entries.map(([name]) => this._atom(name)));
416
+ const targets = new Map();
417
+ entries.forEach(([name, value], i) => {
418
+ const atom = atoms[i];
419
+ if (atom === a.TARGETS || atom === a.TIMESTAMP || atom === a.MULTIPLE) {
420
+ throw new TypeError(`clipboard: ${name} is answered by ntk and cannot be offered as data`);
421
+ }
422
+ targets.set(atom, { type: atom, format: 8, data: encodePayload(value, name) });
423
+ });
424
+ return targets;
425
+ }
426
+
427
+ // A current server timestamp, the way X clients without a triggering
428
+ // event get one: append zero bytes to a property on a window we watch and
429
+ // take the time from the PropertyNotify it generates. Falls back to 0
430
+ // (CurrentTime) rather than making write() hang.
431
+ _serverTime() {
432
+ return new Promise((resolve) => {
433
+ const done = (time) => {
434
+ clearTimeout(timer);
435
+ this._window.removeListener('property', onProperty);
436
+ resolve(time);
437
+ };
438
+ const onProperty = (ev) => {
439
+ if (ev.atom === this._timeProp && ev.state === 0) done(ev.time >>> 0);
440
+ };
441
+ const timer = setTimeout(() => done(0), DEFAULT_TIMEOUT);
442
+ timer.unref?.();
443
+ this._window.on('property', onProperty);
444
+ const empty = Buffer.alloc(0);
445
+ this.X.ChangeProperty(2, this._window.id, this._timeProp, this.X.atoms.STRING, 8, empty);
446
+ });
447
+ }
448
+
449
+ // largest payload one ChangeProperty can carry; beyond it a conversion is
450
+ // answered incrementally. Tests lower it to exercise INCR cheaply.
451
+ _limit() {
452
+ if (this._transferLimit === null) {
453
+ const offered = this.app.display.max_request_length || REQUEST_UNITS_LIMIT;
454
+ const units = Math.min(offered, REQUEST_UNITS_LIMIT);
455
+ this._transferLimit = units * 4 - 24 - 4; // ChangeProperty header, padding
456
+ }
457
+ return this._transferLimit;
458
+ }
459
+
154
460
  _onXEvent(ev) {
155
- if (!this._window || (ev.type !== 29 && ev.type !== 30)) return;
461
+ if (!this._window) return;
462
+ // XFixes events carry a server-assigned type above the core range, so
463
+ // this cannot be confused with core SelectionNotify, which is 31
464
+ if (ev.type === this._fixesFirstEvent && this._selectionWatchers.size) {
465
+ this._fixes?.then(
466
+ (fixes) => this._onSelectionChange(ev, fixes),
467
+ () => {}
468
+ );
469
+ return;
470
+ }
471
+ if (ev.type === 28) {
472
+ // PropertyNotify, state 1 = Deleted: a requestor consumed the INCR
473
+ // chunk we gave it and is asking for the next one
474
+ if (ev.state === 1 && this._transfers.size) this._onChunkConsumed(ev);
475
+ return;
476
+ }
477
+ if (ev.type !== 29 && ev.type !== 30) return;
156
478
  if (ev.owner !== this._window.id) return;
157
479
  if (ev.type === 29) {
158
- // SelectionClear: another client copied — we no longer answer for it
480
+ // SelectionClear: another client copied — we no longer answer for it.
481
+ // Transfers already under way keep their own copy of the data and
482
+ // must still finish (ICCCM 2.7.2).
159
483
  this._owned.delete(ev.selection);
160
484
  } else {
161
485
  this._onSelectionRequest(ev);
162
486
  }
163
487
  }
164
488
 
165
- // we own the selection and somebody is pasting: write the converted data
166
- // to the requestor's property and confirm (or refuse) via SelectionNotify
489
+ // we own the selection and somebody is pasting
167
490
  _onSelectionRequest(ev) {
168
- const X = this.X;
169
- const a = this._atoms;
170
- const text = this._owned.get(ev.selection);
491
+ this._answer(ev).catch(() => {
492
+ // a step of the conversion failed outright (a requestor that vanished
493
+ // mid-paste, most likely) after we had already answered: nothing left
494
+ // to say, and the requestor's own timeout covers it
495
+ });
496
+ }
497
+
498
+ // write the converted data to the requestor's property and confirm (or
499
+ // refuse) via SelectionNotify
500
+ async _answer(ev) {
501
+ const entry = this._owned.get(ev.selection);
171
502
  // obsolete requestors may pass property None — ICCCM says use the
172
503
  // target atom as the property name then
173
504
  let property = ev.property || ev.target;
174
- // the whole answer runs from the packet parser's emit: if the
175
- // connection is closing, drop it instead of throwing (see cleanup.js)
176
- safeRelease(X, () => {
177
- if (text === undefined) {
178
- property = 0; // raced with a SelectionClear we haven't seen yet
179
- } else if (ev.target === a.TARGETS) {
180
- // x11 >= 3.4 encodes a number array at the property's declared
181
- // format, so this reaches the requestor as three CARD32 atoms
182
- X.ChangeProperty(0, ev.requestor, property, X.atoms.ATOM, 32, [
183
- a.TARGETS,
184
- a.UTF8_STRING,
185
- X.atoms.STRING
186
- ]);
187
- } else if (ev.target === a.UTF8_STRING) {
188
- X.ChangeProperty(0, ev.requestor, property, a.UTF8_STRING, 8, Buffer.from(text, 'utf8'));
189
- } else if (ev.target === X.atoms.STRING) {
190
- // latin-1 best effort: codepoints above U+00FF are lossy here;
191
- // modern requestors ask for UTF8_STRING
192
- X.ChangeProperty(0, ev.requestor, property, X.atoms.STRING, 8, Buffer.from(text, 'latin1'));
193
- } else {
194
- // unsupported target (MULTIPLE, TIMESTAMP, images, ...): refuse
195
- // with property None per ICCCM
196
- property = 0;
505
+ let served = false;
506
+ try {
507
+ if (entry) {
508
+ served =
509
+ ev.target === this._atoms.MULTIPLE
510
+ ? await this._serveMultiple(ev, entry, property)
511
+ : await this._serveTarget(ev.requestor, property, ev.target, entry);
197
512
  }
198
- // mask 0: SelectionNotify is addressed to the requestor itself, so it
199
- // goes to the client that created that window rather than to whoever
200
- // selected events on it (ICCCM 2.2)
201
- X.SendEvent(ev.requestor, 0, 0, {
513
+ // entry undefined: raced with a SelectionClear we haven't seen yet
514
+ } catch {
515
+ served = false; // refuse rather than leave the requestor waiting
516
+ }
517
+ if (!served) property = 0;
518
+ // mask 0: SelectionNotify is addressed to the requestor itself, so it
519
+ // goes to the client that created that window rather than to whoever
520
+ // selected events on it (ICCCM 2.2)
521
+ this._send(() =>
522
+ this.X.SendEvent(ev.requestor, 0, 0, {
202
523
  name: 'SelectionNotify',
203
524
  time: ev.time,
204
525
  requestor: ev.requestor,
205
526
  selection: ev.selection,
206
527
  target: ev.target,
207
528
  property
529
+ })
530
+ );
531
+ }
532
+
533
+ // convert one target into one property on the requestor's window;
534
+ // false means "cannot convert", which the caller turns into a refusal
535
+ async _serveTarget(requestor, property, target, entry) {
536
+ const X = this.X;
537
+ const a = this._atoms;
538
+ if (target === a.TARGETS) {
539
+ // x11 >= 3.4 encodes a number array at the property's declared
540
+ // format, so this reaches the requestor as CARD32 atoms
541
+ this._send(() =>
542
+ X.ChangeProperty(0, requestor, property, X.atoms.ATOM, 32, [
543
+ a.TARGETS,
544
+ a.TIMESTAMP,
545
+ a.MULTIPLE,
546
+ ...entry.targets.keys()
547
+ ])
548
+ );
549
+ return true;
550
+ }
551
+ if (target === a.TIMESTAMP) {
552
+ // ICCCM 2.6.2: the timestamp this selection was acquired with, as an
553
+ // INTEGER — Xt-based requestors ask for it before anything else
554
+ this._send(() => X.ChangeProperty(0, requestor, property, X.atoms.INTEGER, 32, [entry.time]));
555
+ return true;
556
+ }
557
+ // MULTIPLE inside MULTIPLE is not allowed, and a bare MULTIPLE never
558
+ // reaches here (its own branch runs first)
559
+ if (target === a.MULTIPLE) return false;
560
+ const value = entry.targets.get(target);
561
+ if (!value) return false;
562
+ if (value.data.length > this._limit()) {
563
+ await this._startIncr(requestor, property, value);
564
+ return true;
565
+ }
566
+ this._send(() =>
567
+ X.ChangeProperty(0, requestor, property, value.type, value.format, value.data)
568
+ );
569
+ return true;
570
+ }
571
+
572
+ // MULTIPLE (ICCCM 2.6.2): the requestor left a list of (target, property)
573
+ // pairs on its own window; convert each one and hand the list back with
574
+ // None in place of the properties we could not fill.
575
+ async _serveMultiple(ev, entry, property) {
576
+ const pairs = await this._getProperty(property, ev.requestor, 0);
577
+ // the list is defined as ATOM_PAIR, but requestors have been known to
578
+ // label it otherwise: what matters is that it decodes as 32-bit pairs
579
+ if (!pairs || pairs.format !== 32 || !pairs.data.length || pairs.data.length % 8) return false;
580
+ const list = Buffer.from(pairs.data);
581
+ let refused = false;
582
+ for (let o = 0; o < list.length; o += 8) {
583
+ const target = list.readUInt32LE(o);
584
+ const prop = list.readUInt32LE(o + 4);
585
+ if (!prop) continue; // already None
586
+ if (!(await this._serveTarget(ev.requestor, prop, target, entry))) {
587
+ list.writeUInt32LE(0, o + 4);
588
+ refused = true;
589
+ }
590
+ }
591
+ if (refused) {
592
+ const type = pairs.type || this._atoms.ATOM_PAIR;
593
+ this._send(() => this.X.ChangeProperty(0, ev.requestor, property, type, 32, list));
594
+ }
595
+ return true;
596
+ }
597
+
598
+ // ---- INCR, owner side (ICCCM 2.7.2): the mirror of _fetchProperty ----
599
+
600
+ // Answer the conversion with an INCR property holding a lower bound on
601
+ // the byte count; the requestor deleting that property starts the chunks.
602
+ async _startIncr(requestor, property, value) {
603
+ const key = `${requestor}:${property}`;
604
+ this._endTransfer(key); // a new conversion supersedes an unfinished one
605
+ await this._watch(requestor);
606
+ const transfer = {
607
+ key,
608
+ requestor,
609
+ property,
610
+ type: value.type,
611
+ format: value.format,
612
+ data: value.data,
613
+ offset: 0,
614
+ terminated: false,
615
+ timer: null
616
+ };
617
+ this._transfers.set(key, transfer);
618
+ this._touch(transfer);
619
+ this._send(() =>
620
+ this.X.ChangeProperty(0, requestor, property, this._atoms.INCR, 32, [value.data.length])
621
+ );
622
+ }
623
+
624
+ _onChunkConsumed(ev) {
625
+ const transfer = this._transfers.get(`${ev.wid}:${ev.atom}`);
626
+ if (!transfer) return;
627
+ if (transfer.terminated) {
628
+ // the zero-length property that ended the transfer has been read
629
+ this._endTransfer(transfer.key);
630
+ return;
631
+ }
632
+ const chunk = transfer.data.subarray(transfer.offset, transfer.offset + this._limit());
633
+ transfer.offset += chunk.length;
634
+ // an empty chunk is the end marker, not a chunk
635
+ if (chunk.length === 0) transfer.terminated = true;
636
+ this._touch(transfer);
637
+ this._send(() =>
638
+ this.X.ChangeProperty(
639
+ 0,
640
+ transfer.requestor,
641
+ transfer.property,
642
+ transfer.type,
643
+ transfer.format,
644
+ chunk
645
+ )
646
+ );
647
+ }
648
+
649
+ _touch(transfer) {
650
+ clearTimeout(transfer.timer);
651
+ transfer.timer = setTimeout(() => this._endTransfer(transfer.key), this._incrTimeout);
652
+ transfer.timer.unref?.();
653
+ }
654
+
655
+ _endTransfer(key) {
656
+ const transfer = this._transfers.get(key);
657
+ if (!transfer) return;
658
+ clearTimeout(transfer.timer);
659
+ this._transfers.delete(key);
660
+ this._unwatch(transfer.requestor);
661
+ }
662
+
663
+ // PropertyNotify on the requestor's window is how the chunks are paced.
664
+ // Event masks are per-client, so ours is ours to set — but restore what
665
+ // we found: with a self-paste the requestor is our own helper window,
666
+ // whose PropertyChange the read path depends on.
667
+ async _watch(requestor) {
668
+ let watch = this._watched.get(requestor);
669
+ if (!watch) {
670
+ // register before the round trip, not after it: ICCCM lets a
671
+ // requestor run several conversions at once (on distinct properties),
672
+ // and those must share one watch rather than race to install it
673
+ watch = { count: 0, mask: 0 };
674
+ watch.ready = new Promise((resolve, reject) =>
675
+ this.X.GetWindowAttributes(requestor, (err, attrs) =>
676
+ err ? reject(err) : resolve(attrs.myEventMasks)
677
+ )
678
+ ).then((mask) => {
679
+ watch.mask = mask;
680
+ if (mask & x11.eventMask.PropertyChange) return;
681
+ this._send(() =>
682
+ this.X.ChangeWindowAttributes(
683
+ requestor,
684
+ { eventMask: mask | x11.eventMask.PropertyChange },
685
+ () => true
686
+ )
687
+ );
208
688
  });
209
- });
689
+ this._watched.set(requestor, watch);
690
+ }
691
+ watch.count++;
692
+ try {
693
+ await watch.ready;
694
+ } catch (err) {
695
+ this._unwatch(requestor); // the requestor is gone; do not keep its entry
696
+ throw err;
697
+ }
210
698
  }
211
699
 
700
+ _unwatch(requestor) {
701
+ const watch = this._watched.get(requestor);
702
+ if (!watch || --watch.count > 0) return;
703
+ this._watched.delete(requestor);
704
+ if (watch.mask & x11.eventMask.PropertyChange) return;
705
+ // () => true swallows the BadWindow of a requestor that destroyed its
706
+ // window as soon as the paste completed — the usual ending
707
+ this._send(() =>
708
+ this.X.ChangeWindowAttributes(requestor, { eventMask: watch.mask }, () => true)
709
+ );
710
+ }
711
+
712
+ // ---- INCR, requestor side ----
713
+
212
714
  // ConvertSelection and wait for the owner's SelectionNotify answer
213
715
  _convert(sel, target, prop, selectionName, timeout) {
214
716
  return new Promise((resolve, reject) => {
@@ -236,11 +738,11 @@ export default class Clipboard {
236
738
  });
237
739
  }
238
740
 
239
- _getProperty(prop) {
741
+ _getProperty(prop, wid = this._window.id, del = 1) {
240
742
  // delete=true: for plain transfers frees the property; for INCR chunks
241
743
  // it doubles as the "send the next chunk" handshake
242
744
  return new Promise((resolve, reject) =>
243
- this.X.GetProperty(1, this._window.id, prop, 0, 0, 0x1fffffff, (err, res) =>
745
+ this.X.GetProperty(del, wid, prop, 0, 0, 0x1fffffff, (err, res) =>
244
746
  err ? reject(err) : resolve(res)
245
747
  )
246
748
  );