ntk 5.2.0 → 5.4.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
  );
@@ -68,6 +114,59 @@ export default class Clipboard {
68
114
  }
69
115
  }
70
116
 
117
+ /**
118
+ * Give a selection back: stop owning it, and stop answering for it.
119
+ *
120
+ * await app.clipboard.clear(); // CLIPBOARD
121
+ * await app.clipboard.clear('XdndSelection'); // at the end of a drag
122
+ *
123
+ * The counterpart to `write()`, which otherwise holds a selection until
124
+ * another client takes it or the app exits. Two things want this: a drag
125
+ * source, which should stop offering its payload once the drag is over
126
+ * (a stale offer answered later is exactly what XDND's "throw out
127
+ * extremely old data" rule is about), and apps that clear the clipboard
128
+ * on purpose — a password manager, say.
129
+ *
130
+ * Clearing a selection this app does not own does nothing, and in
131
+ * particular sends nothing: `SetSelectionOwner(None)` from a non-owner
132
+ * would take the selection away from whoever legitimately holds it.
133
+ *
134
+ * A transfer already in flight still completes — an INCR transfer keeps
135
+ * its own copy of the payload (ICCCM 2.7.2), the same as when another
136
+ * client takes the selection from us.
137
+ *
138
+ * @param {string} [selection] selection atom name, default 'CLIPBOARD'
139
+ * @returns {Promise<void>}
140
+ */
141
+ async clear(selection = 'CLIPBOARD') {
142
+ // nothing has ever been written, so nothing can be owned: answer
143
+ // without interning an atom or creating the helper window
144
+ if (!this._owned.size) return;
145
+ const sel = await this._atom(selection);
146
+ const entry = this._owned.get(sel);
147
+ if (!entry) return;
148
+ this._owned.delete(sel);
149
+ // ICCCM 2.3.1: release with the timestamp the selection was acquired
150
+ // with, not a fresh one. A SetSelectionOwner is ignored when its time is
151
+ // earlier than the selection's current last-change time, so the old
152
+ // stamp is what makes this lose to a client that has taken the selection
153
+ // since — where a fresh one would take it away from them.
154
+ this.X.SetSelectionOwner(0, sel, entry.time);
155
+ // The server answers the release with a SelectionClear addressed to the
156
+ // helper window; _onXEvent deletes an _owned entry that is already gone,
157
+ // which is harmless.
158
+ //
159
+ // GetSelectionOwner here is a barrier, not a check: requests are
160
+ // buffered (one socket write per frame) and it is a reply that forces
161
+ // the flush, so without it the release could still be sitting in our
162
+ // output buffer when the caller's next await resumes. Whoever owns the
163
+ // selection afterwards is not our business — we only promised to stop
164
+ // being the owner.
165
+ await new Promise((resolve, reject) =>
166
+ this.X.GetSelectionOwner(sel, (err) => (err ? reject(err) : resolve()))
167
+ );
168
+ }
169
+
71
170
  /**
72
171
  * Read the current text of a selection from whoever owns it.
73
172
  * Rejects when the selection has no owner, when the owner supports
@@ -75,29 +174,111 @@ export default class Clipboard {
75
174
  * (`timeout` ms, default 2000).
76
175
  *
77
176
  * @param {object} [options] { selection: 'CLIPBOARD' | 'PRIMARY' | ...,
78
- * timeout: ms to wait for the owner at each protocol step }
79
- * @returns {Promise<string>}
177
+ * timeout: ms to wait for the owner at each protocol step;
178
+ * target: one named target, returned as bytes, instead of text;
179
+ * time: the server timestamp of the event that asked for the paste
180
+ * (ICCCM 2.4) — see `targets()` for why it is worth passing }
181
+ * @returns {Promise<string|Buffer>}
182
+ */
183
+ read({ selection = 'CLIPBOARD', timeout = DEFAULT_TIMEOUT, target, time } = {}) {
184
+ return this._serialize(() =>
185
+ target === undefined
186
+ ? this._read(selection, timeout, time)
187
+ : this._readTarget(selection, target, timeout, time)
188
+ );
189
+ }
190
+
191
+ /**
192
+ * What the current owner of a selection can convert to, as target names.
193
+ *
194
+ * const offered = await app.clipboard.targets();
195
+ * if (offered.includes('image/png')) { ... }
196
+ *
197
+ * This is the question to ask before `read({ target })`: an owner answers
198
+ * `TARGETS` cheaply, where guessing costs a failed conversion per guess.
199
+ * Returns `[]` when nothing owns the selection.
200
+ *
201
+ * `time` is the timestamp of the event that asked for the data. ICCCM 2.4
202
+ * says to pass it rather than CurrentTime, so that an owner which has
203
+ * since replaced its data can tell that the request is for the older
204
+ * value; XDND makes it binding, since a drop must convert with the
205
+ * timestamp from its `XdndDrop` message. Omitting it means CurrentTime,
206
+ * which every mainstream owner accepts and a strict one may not.
207
+ *
208
+ * @param {object} [options] { selection, timeout, time }
209
+ * @returns {Promise<string[]>}
80
210
  */
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);
211
+ targets({ selection = 'CLIPBOARD', timeout = DEFAULT_TIMEOUT, time } = {}) {
212
+ return this._serialize(() => this._targets(selection, timeout, time));
213
+ }
214
+
215
+ /** Concurrent conversions would share the one transfer property on the
216
+ * helper window, so let each finish before the next starts. */
217
+ _serialize(run) {
85
218
  const result = this._readQueue.then(run, run);
86
219
  this._readQueue = result.catch(() => {});
87
220
  return result;
88
221
  }
89
222
 
90
- async _read(selection, timeout) {
223
+ async _targets(selection, timeout, time) {
224
+ await this._ensure();
225
+ const sel = await this._atom(selection);
226
+ const prop = this._transferProp;
227
+ const notify = await this._convert(sel, this._atoms.TARGETS, prop, selection, timeout, time);
228
+ if (notify.property === 0) return [];
229
+ const { data, format } = await this._fetchProperty(prop, selection, timeout);
230
+ // an INCR reassembly reports no format, and a target list is far too
231
+ // small to arrive that way — so only a stated non-32 format disqualifies
232
+ if (format !== undefined && format !== 32) return [];
233
+ const atoms = [];
234
+ for (let o = 0; o + 4 <= data.length; o += 4) atoms.push(data.readUInt32LE(o));
235
+ const names = await Promise.all(atoms.map((atom) => this._atomName(atom)));
236
+ return names.filter(Boolean);
237
+ }
238
+
239
+ /**
240
+ * Read one named target, as bytes. The mirror of writing one: what
241
+ * `write({ 'image/png': buf })` publishes, this is how another ntk app
242
+ * gets it back.
243
+ */
244
+ async _readTarget(selection, target, timeout, time) {
245
+ await this._ensure();
246
+ const sel = await this._atom(selection);
247
+ const atom = await this._atom(target);
248
+ const prop = this._transferProp;
249
+ const notify = await this._convert(sel, atom, prop, selection, timeout, time);
250
+ if (notify.property === 0) {
251
+ const owner = await new Promise((resolve, reject) =>
252
+ this.X.GetSelectionOwner(sel, (err, wid) => (err ? reject(err) : resolve(wid)))
253
+ );
254
+ throw new Error(
255
+ owner
256
+ ? `clipboard: ${selection} selection owner cannot convert to ${target}`
257
+ : `clipboard: nothing to paste — ${selection} selection has no owner`
258
+ );
259
+ }
260
+ const { data } = await this._fetchProperty(prop, selection, timeout);
261
+ return data;
262
+ }
263
+
264
+ _atomName(atom) {
265
+ return new Promise((resolve) =>
266
+ this.X.GetAtomName(atom, (err, name) => resolve(err ? null : name))
267
+ );
268
+ }
269
+
270
+ async _read(selection, timeout, time) {
91
271
  await this._ensure();
92
272
  const X = this.X;
93
273
  const sel = await this._atom(selection);
94
274
  const prop = this._transferProp;
95
275
 
96
- let notify = await this._convert(sel, this._atoms.UTF8_STRING, prop, selection, timeout);
276
+ let notify = await this._convert(sel, this._atoms.UTF8_STRING, prop, selection, timeout, time);
97
277
  if (notify.property === 0) {
98
278
  // property None: no owner, or the owner refused UTF8_STRING (old
99
- // Xt/Motif apps) — ask again for latin-1 STRING before giving up
100
- notify = await this._convert(sel, X.atoms.STRING, prop, selection, timeout);
279
+ // Xt/Motif apps) — ask again for latin-1 STRING before giving up.
280
+ // Same timestamp: this is a retry of one paste, not a second one.
281
+ notify = await this._convert(sel, X.atoms.STRING, prop, selection, timeout, time);
101
282
  }
102
283
  if (notify.property === 0) {
103
284
  const owner = await new Promise((resolve, reject) =>
@@ -126,23 +307,143 @@ export default class Clipboard {
126
307
  height: 1,
127
308
  eventMask: x11.eventMask.PropertyChange
128
309
  });
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 };
310
+ const [TARGETS, TIMESTAMP, MULTIPLE, ATOM_PAIR, UTF8_STRING, INCR, transferProp, timeProp] =
311
+ await Promise.all(
312
+ [
313
+ 'TARGETS',
314
+ 'TIMESTAMP',
315
+ 'MULTIPLE',
316
+ 'ATOM_PAIR',
317
+ 'UTF8_STRING',
318
+ 'INCR',
319
+ 'NTK_SELECTION',
320
+ 'NTK_TIME'
321
+ ].map((name) => this._atom(name))
322
+ );
323
+ this._atoms = { TARGETS, TIMESTAMP, MULTIPLE, ATOM_PAIR, UTF8_STRING, INCR };
136
324
  this._transferProp = transferProp;
325
+ this._timeProp = timeProp;
137
326
  // SelectionRequest/SelectionClear carry the owner in ev.owner, not
138
327
  // ev.wid, so node-x11's event_consumers routing (keyed on ev.wid)
139
328
  // never delivers them to the Window wrapper — listen on the raw
140
- // client instead
329
+ // client instead. INCR chunk pacing needs PropertyNotify for the
330
+ // requestor's window, which is not ours either.
141
331
  this.X.on('event', this._onXEvent);
142
332
  })();
143
333
  return this._ready;
144
334
  }
145
335
 
336
+ /**
337
+ * Call `handler` whenever a selection changes hands.
338
+ *
339
+ * const unwatch = await app.clipboard.watch('CLIPBOARD', (ev) => {
340
+ * pasteItem.disabled = ev.owner === 0;
341
+ * });
342
+ * unwatch();
343
+ *
344
+ * The alternative is polling `read()`, which is a full conversion round
345
+ * trip against whatever foreign client owns the selection — and a two
346
+ * second wait when that client is wedged. This is a server-side
347
+ * subscription instead: the server tells us, and it costs nothing until
348
+ * something actually changes.
349
+ *
350
+ * `ev` is `{ selection, owner, timestamp, selectionTimestamp, reason }`,
351
+ * where `reason` is `'new-owner'` when someone took the selection,
352
+ * `'destroyed'` when the owning window went away, and `'closed'` when the
353
+ * owning client disconnected. `owner` is 0 when the selection ends up
354
+ * unowned, which is the case an edit menu wants: nothing to paste.
355
+ *
356
+ * Watchers share one server-side registration per selection, so watching
357
+ * the same selection twice costs one extra callback and no extra protocol.
358
+ *
359
+ * @param {string} selection selection atom name, e.g. 'CLIPBOARD' or 'PRIMARY'
360
+ * @param {function} handler called with the event
361
+ * @returns {Promise<function>} call it to stop watching
362
+ */
363
+ async watch(selection, handler) {
364
+ if (typeof handler !== 'function') {
365
+ throw new TypeError('clipboard: watch needs a handler function');
366
+ }
367
+ await this._ensure();
368
+ const fixes = await this._ensureFixes();
369
+ const sel = await this._atom(selection);
370
+
371
+ let entry = this._selectionWatchers.get(sel);
372
+ if (!entry) {
373
+ entry = { name: selection, handlers: new Set() };
374
+ this._selectionWatchers.set(sel, entry);
375
+ const mask =
376
+ fixes.SelectionEventMask.SetSelectionOwner |
377
+ fixes.SelectionEventMask.SelectionWindowDestroy |
378
+ fixes.SelectionEventMask.SelectionClientClose;
379
+ safeRelease(this.X, () => fixes.SelectSelectionInput(this._window.id, sel, mask));
380
+ }
381
+ entry.handlers.add(handler);
382
+
383
+ let stopped = false;
384
+ return () => {
385
+ if (stopped) return;
386
+ stopped = true;
387
+ const current = this._selectionWatchers.get(sel);
388
+ if (!current) return;
389
+ current.handlers.delete(handler);
390
+ if (current.handlers.size) return;
391
+ // last watcher for this selection: drop the server-side registration
392
+ this._selectionWatchers.delete(sel);
393
+ safeRelease(this.X, () => fixes.SelectSelectionInput(this._window.id, sel, 0));
394
+ };
395
+ }
396
+
397
+ /** XFixes, required once. Rejects with something readable on a server
398
+ * without it — every X server since about 2004 has had it, so this is a
399
+ * "your server is unusual" error rather than a routine fallback. */
400
+ _ensureFixes() {
401
+ if (this._fixes) return this._fixes;
402
+ this._fixes = new Promise((resolve, reject) => {
403
+ this.X.require('fixes', (err, fixes) => {
404
+ if (err || !fixes) {
405
+ this._fixes = null; // let a later call try again
406
+ return reject(
407
+ new Error(
408
+ 'clipboard: this X server has no XFixes extension, so selection ' +
409
+ `changes cannot be watched${err ? `: ${err.message}` : ''}`
410
+ )
411
+ );
412
+ }
413
+ this._fixesFirstEvent = fixes.firstEvent;
414
+ resolve(fixes);
415
+ });
416
+ });
417
+ return this._fixes;
418
+ }
419
+
420
+ /** An XFixes SelectionNotify: translate the subtype and fan out. */
421
+ _onSelectionChange(ev, fixes) {
422
+ const entry = this._selectionWatchers.get(ev.selection);
423
+ if (!entry) return;
424
+ const reason =
425
+ ev.subtype === fixes.SelectionEvent.SelectionWindowDestroy
426
+ ? 'destroyed'
427
+ : ev.subtype === fixes.SelectionEvent.SelectionClientClose
428
+ ? 'closed'
429
+ : 'new-owner';
430
+ const detail = {
431
+ selection: entry.name,
432
+ owner: ev.owner,
433
+ timestamp: ev.timestamp,
434
+ selectionTimestamp: ev.selectionTimestamp,
435
+ reason
436
+ };
437
+ // a throwing handler must not cost the others their event
438
+ for (const handler of [...entry.handlers]) {
439
+ try {
440
+ handler(detail);
441
+ } catch (err) {
442
+ console.warn(`ntk: clipboard watch handler threw: ${err.message}`);
443
+ }
444
+ }
445
+ }
446
+
146
447
  _atom(name) {
147
448
  // predefined atoms (PRIMARY = 1, STRING = 31, ...) resolve without a
148
449
  // round-trip; node-x11 caches interned ones after the first reply
@@ -151,66 +452,334 @@ export default class Clipboard {
151
452
  );
152
453
  }
153
454
 
455
+ // every server call from an event handler runs inside the packet parser's
456
+ // emit: if the connection is closing, drop it instead of throwing
457
+ _send(fn) {
458
+ safeRelease(this.X, fn);
459
+ }
460
+
461
+ // name -> payload map for one selection, target names resolved to atoms
462
+ async _encode(data) {
463
+ const a = this._atoms;
464
+ if (data === null || typeof data !== 'object') {
465
+ const text = String(data);
466
+ const STRING = this.X.atoms.STRING;
467
+ return new Map([
468
+ [a.UTF8_STRING, { type: a.UTF8_STRING, format: 8, data: Buffer.from(text, 'utf8') }],
469
+ [STRING, { type: STRING, format: 8, data: Buffer.from(text, 'latin1') }]
470
+ ]);
471
+ }
472
+ if (isBinary(data)) {
473
+ throw new TypeError(
474
+ "clipboard: binary data needs a target name — write({ 'image/png': buffer })"
475
+ );
476
+ }
477
+ const entries = data instanceof Map ? [...data] : Object.entries(data);
478
+ if (!entries.length) throw new TypeError('clipboard: write() needs at least one target');
479
+ const atoms = await Promise.all(entries.map(([name]) => this._atom(name)));
480
+ const targets = new Map();
481
+ entries.forEach(([name, value], i) => {
482
+ const atom = atoms[i];
483
+ if (atom === a.TARGETS || atom === a.TIMESTAMP || atom === a.MULTIPLE) {
484
+ throw new TypeError(`clipboard: ${name} is answered by ntk and cannot be offered as data`);
485
+ }
486
+ targets.set(atom, { type: atom, format: 8, data: encodePayload(value, name) });
487
+ });
488
+ return targets;
489
+ }
490
+
491
+ // A current server timestamp, the way X clients without a triggering
492
+ // event get one: append zero bytes to a property on a window we watch and
493
+ // take the time from the PropertyNotify it generates. Falls back to 0
494
+ // (CurrentTime) rather than making write() hang.
495
+ _serverTime() {
496
+ return new Promise((resolve) => {
497
+ const done = (time) => {
498
+ clearTimeout(timer);
499
+ this._window.removeListener('property', onProperty);
500
+ resolve(time);
501
+ };
502
+ const onProperty = (ev) => {
503
+ if (ev.atom === this._timeProp && ev.state === 0) done(ev.time >>> 0);
504
+ };
505
+ const timer = setTimeout(() => done(0), DEFAULT_TIMEOUT);
506
+ timer.unref?.();
507
+ this._window.on('property', onProperty);
508
+ const empty = Buffer.alloc(0);
509
+ this.X.ChangeProperty(2, this._window.id, this._timeProp, this.X.atoms.STRING, 8, empty);
510
+ });
511
+ }
512
+
513
+ // largest payload one ChangeProperty can carry; beyond it a conversion is
514
+ // answered incrementally. Tests lower it to exercise INCR cheaply.
515
+ _limit() {
516
+ if (this._transferLimit === null) {
517
+ const offered = this.app.display.max_request_length || REQUEST_UNITS_LIMIT;
518
+ const units = Math.min(offered, REQUEST_UNITS_LIMIT);
519
+ this._transferLimit = units * 4 - 24 - 4; // ChangeProperty header, padding
520
+ }
521
+ return this._transferLimit;
522
+ }
523
+
154
524
  _onXEvent(ev) {
155
- if (!this._window || (ev.type !== 29 && ev.type !== 30)) return;
525
+ if (!this._window) return;
526
+ // XFixes events carry a server-assigned type above the core range, so
527
+ // this cannot be confused with core SelectionNotify, which is 31
528
+ if (ev.type === this._fixesFirstEvent && this._selectionWatchers.size) {
529
+ this._fixes?.then(
530
+ (fixes) => this._onSelectionChange(ev, fixes),
531
+ () => {}
532
+ );
533
+ return;
534
+ }
535
+ if (ev.type === 28) {
536
+ // PropertyNotify, state 1 = Deleted: a requestor consumed the INCR
537
+ // chunk we gave it and is asking for the next one
538
+ if (ev.state === 1 && this._transfers.size) this._onChunkConsumed(ev);
539
+ return;
540
+ }
541
+ if (ev.type !== 29 && ev.type !== 30) return;
156
542
  if (ev.owner !== this._window.id) return;
157
543
  if (ev.type === 29) {
158
- // SelectionClear: another client copied — we no longer answer for it
544
+ // SelectionClear: another client copied — we no longer answer for it.
545
+ // Transfers already under way keep their own copy of the data and
546
+ // must still finish (ICCCM 2.7.2).
159
547
  this._owned.delete(ev.selection);
160
548
  } else {
161
549
  this._onSelectionRequest(ev);
162
550
  }
163
551
  }
164
552
 
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
553
+ // we own the selection and somebody is pasting
167
554
  _onSelectionRequest(ev) {
168
- const X = this.X;
169
- const a = this._atoms;
170
- const text = this._owned.get(ev.selection);
555
+ this._answer(ev).catch(() => {
556
+ // a step of the conversion failed outright (a requestor that vanished
557
+ // mid-paste, most likely) after we had already answered: nothing left
558
+ // to say, and the requestor's own timeout covers it
559
+ });
560
+ }
561
+
562
+ // write the converted data to the requestor's property and confirm (or
563
+ // refuse) via SelectionNotify
564
+ async _answer(ev) {
565
+ const entry = this._owned.get(ev.selection);
171
566
  // obsolete requestors may pass property None — ICCCM says use the
172
567
  // target atom as the property name then
173
568
  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;
569
+ let served = false;
570
+ try {
571
+ if (entry) {
572
+ served =
573
+ ev.target === this._atoms.MULTIPLE
574
+ ? await this._serveMultiple(ev, entry, property)
575
+ : await this._serveTarget(ev.requestor, property, ev.target, entry);
197
576
  }
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, {
577
+ // entry undefined: raced with a SelectionClear we haven't seen yet
578
+ } catch {
579
+ served = false; // refuse rather than leave the requestor waiting
580
+ }
581
+ if (!served) property = 0;
582
+ // mask 0: SelectionNotify is addressed to the requestor itself, so it
583
+ // goes to the client that created that window rather than to whoever
584
+ // selected events on it (ICCCM 2.2)
585
+ this._send(() =>
586
+ this.X.SendEvent(ev.requestor, 0, 0, {
202
587
  name: 'SelectionNotify',
203
588
  time: ev.time,
204
589
  requestor: ev.requestor,
205
590
  selection: ev.selection,
206
591
  target: ev.target,
207
592
  property
593
+ })
594
+ );
595
+ }
596
+
597
+ // convert one target into one property on the requestor's window;
598
+ // false means "cannot convert", which the caller turns into a refusal
599
+ async _serveTarget(requestor, property, target, entry) {
600
+ const X = this.X;
601
+ const a = this._atoms;
602
+ if (target === a.TARGETS) {
603
+ // x11 >= 3.4 encodes a number array at the property's declared
604
+ // format, so this reaches the requestor as CARD32 atoms
605
+ this._send(() =>
606
+ X.ChangeProperty(0, requestor, property, X.atoms.ATOM, 32, [
607
+ a.TARGETS,
608
+ a.TIMESTAMP,
609
+ a.MULTIPLE,
610
+ ...entry.targets.keys()
611
+ ])
612
+ );
613
+ return true;
614
+ }
615
+ if (target === a.TIMESTAMP) {
616
+ // ICCCM 2.6.2: the timestamp this selection was acquired with, as an
617
+ // INTEGER — Xt-based requestors ask for it before anything else
618
+ this._send(() => X.ChangeProperty(0, requestor, property, X.atoms.INTEGER, 32, [entry.time]));
619
+ return true;
620
+ }
621
+ // MULTIPLE inside MULTIPLE is not allowed, and a bare MULTIPLE never
622
+ // reaches here (its own branch runs first)
623
+ if (target === a.MULTIPLE) return false;
624
+ const value = entry.targets.get(target);
625
+ if (!value) return false;
626
+ if (value.data.length > this._limit()) {
627
+ await this._startIncr(requestor, property, value);
628
+ return true;
629
+ }
630
+ this._send(() =>
631
+ X.ChangeProperty(0, requestor, property, value.type, value.format, value.data)
632
+ );
633
+ return true;
634
+ }
635
+
636
+ // MULTIPLE (ICCCM 2.6.2): the requestor left a list of (target, property)
637
+ // pairs on its own window; convert each one and hand the list back with
638
+ // None in place of the properties we could not fill.
639
+ async _serveMultiple(ev, entry, property) {
640
+ const pairs = await this._getProperty(property, ev.requestor, 0);
641
+ // the list is defined as ATOM_PAIR, but requestors have been known to
642
+ // label it otherwise: what matters is that it decodes as 32-bit pairs
643
+ if (!pairs || pairs.format !== 32 || !pairs.data.length || pairs.data.length % 8) return false;
644
+ const list = Buffer.from(pairs.data);
645
+ let refused = false;
646
+ for (let o = 0; o < list.length; o += 8) {
647
+ const target = list.readUInt32LE(o);
648
+ const prop = list.readUInt32LE(o + 4);
649
+ if (!prop) continue; // already None
650
+ if (!(await this._serveTarget(ev.requestor, prop, target, entry))) {
651
+ list.writeUInt32LE(0, o + 4);
652
+ refused = true;
653
+ }
654
+ }
655
+ if (refused) {
656
+ const type = pairs.type || this._atoms.ATOM_PAIR;
657
+ this._send(() => this.X.ChangeProperty(0, ev.requestor, property, type, 32, list));
658
+ }
659
+ return true;
660
+ }
661
+
662
+ // ---- INCR, owner side (ICCCM 2.7.2): the mirror of _fetchProperty ----
663
+
664
+ // Answer the conversion with an INCR property holding a lower bound on
665
+ // the byte count; the requestor deleting that property starts the chunks.
666
+ async _startIncr(requestor, property, value) {
667
+ const key = `${requestor}:${property}`;
668
+ this._endTransfer(key); // a new conversion supersedes an unfinished one
669
+ await this._watch(requestor);
670
+ const transfer = {
671
+ key,
672
+ requestor,
673
+ property,
674
+ type: value.type,
675
+ format: value.format,
676
+ data: value.data,
677
+ offset: 0,
678
+ terminated: false,
679
+ timer: null
680
+ };
681
+ this._transfers.set(key, transfer);
682
+ this._touch(transfer);
683
+ this._send(() =>
684
+ this.X.ChangeProperty(0, requestor, property, this._atoms.INCR, 32, [value.data.length])
685
+ );
686
+ }
687
+
688
+ _onChunkConsumed(ev) {
689
+ const transfer = this._transfers.get(`${ev.wid}:${ev.atom}`);
690
+ if (!transfer) return;
691
+ if (transfer.terminated) {
692
+ // the zero-length property that ended the transfer has been read
693
+ this._endTransfer(transfer.key);
694
+ return;
695
+ }
696
+ const chunk = transfer.data.subarray(transfer.offset, transfer.offset + this._limit());
697
+ transfer.offset += chunk.length;
698
+ // an empty chunk is the end marker, not a chunk
699
+ if (chunk.length === 0) transfer.terminated = true;
700
+ this._touch(transfer);
701
+ this._send(() =>
702
+ this.X.ChangeProperty(
703
+ 0,
704
+ transfer.requestor,
705
+ transfer.property,
706
+ transfer.type,
707
+ transfer.format,
708
+ chunk
709
+ )
710
+ );
711
+ }
712
+
713
+ _touch(transfer) {
714
+ clearTimeout(transfer.timer);
715
+ transfer.timer = setTimeout(() => this._endTransfer(transfer.key), this._incrTimeout);
716
+ transfer.timer.unref?.();
717
+ }
718
+
719
+ _endTransfer(key) {
720
+ const transfer = this._transfers.get(key);
721
+ if (!transfer) return;
722
+ clearTimeout(transfer.timer);
723
+ this._transfers.delete(key);
724
+ this._unwatch(transfer.requestor);
725
+ }
726
+
727
+ // PropertyNotify on the requestor's window is how the chunks are paced.
728
+ // Event masks are per-client, so ours is ours to set — but restore what
729
+ // we found: with a self-paste the requestor is our own helper window,
730
+ // whose PropertyChange the read path depends on.
731
+ async _watch(requestor) {
732
+ let watch = this._watched.get(requestor);
733
+ if (!watch) {
734
+ // register before the round trip, not after it: ICCCM lets a
735
+ // requestor run several conversions at once (on distinct properties),
736
+ // and those must share one watch rather than race to install it
737
+ watch = { count: 0, mask: 0 };
738
+ watch.ready = new Promise((resolve, reject) =>
739
+ this.X.GetWindowAttributes(requestor, (err, attrs) =>
740
+ err ? reject(err) : resolve(attrs.myEventMasks)
741
+ )
742
+ ).then((mask) => {
743
+ watch.mask = mask;
744
+ if (mask & x11.eventMask.PropertyChange) return;
745
+ this._send(() =>
746
+ this.X.ChangeWindowAttributes(
747
+ requestor,
748
+ { eventMask: mask | x11.eventMask.PropertyChange },
749
+ () => true
750
+ )
751
+ );
208
752
  });
209
- });
753
+ this._watched.set(requestor, watch);
754
+ }
755
+ watch.count++;
756
+ try {
757
+ await watch.ready;
758
+ } catch (err) {
759
+ this._unwatch(requestor); // the requestor is gone; do not keep its entry
760
+ throw err;
761
+ }
210
762
  }
211
763
 
212
- // ConvertSelection and wait for the owner's SelectionNotify answer
213
- _convert(sel, target, prop, selectionName, timeout) {
764
+ _unwatch(requestor) {
765
+ const watch = this._watched.get(requestor);
766
+ if (!watch || --watch.count > 0) return;
767
+ this._watched.delete(requestor);
768
+ if (watch.mask & x11.eventMask.PropertyChange) return;
769
+ // () => true swallows the BadWindow of a requestor that destroyed its
770
+ // window as soon as the paste completed — the usual ending
771
+ this._send(() =>
772
+ this.X.ChangeWindowAttributes(requestor, { eventMask: watch.mask }, () => true)
773
+ );
774
+ }
775
+
776
+ // ---- INCR, requestor side ----
777
+
778
+ // ConvertSelection and wait for the owner's SelectionNotify answer.
779
+ // `time` is the timestamp of the event that asked for the data; ICCCM 2.4
780
+ // says to send it rather than CurrentTime. Undefined coerces to 0, which
781
+ // is CurrentTime — the documented default for callers with no event.
782
+ _convert(sel, target, prop, selectionName, timeout, time) {
214
783
  return new Promise((resolve, reject) => {
215
784
  const X = this.X;
216
785
  const wid = this._window.id;
@@ -232,15 +801,15 @@ export default class Clipboard {
232
801
  X.removeListener('event', onEvent);
233
802
  };
234
803
  X.on('event', onEvent);
235
- X.ConvertSelection(wid, sel, target, prop, 0);
804
+ X.ConvertSelection(wid, sel, target, prop, time >>> 0);
236
805
  });
237
806
  }
238
807
 
239
- _getProperty(prop) {
808
+ _getProperty(prop, wid = this._window.id, del = 1) {
240
809
  // delete=true: for plain transfers frees the property; for INCR chunks
241
810
  // it doubles as the "send the next chunk" handshake
242
811
  return new Promise((resolve, reject) =>
243
- this.X.GetProperty(1, this._window.id, prop, 0, 0, 0x1fffffff, (err, res) =>
812
+ this.X.GetProperty(del, wid, prop, 0, 0, 0x1fffffff, (err, res) =>
244
813
  err ? reject(err) : resolve(res)
245
814
  )
246
815
  );