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/app.js +4 -2
- package/lib/clipboard.js +652 -83
- package/lib/fontconfig.js +99 -12
- package/lib/image.js +13 -19
- package/lib/imagedata.js +388 -0
- package/lib/index.js +32 -2
- package/lib/renderingcontext_2d.js +416 -42
- package/lib/surface.js +118 -0
- package/lib/text/fontmanager.js +27 -4
- package/lib/text/fontsource.js +255 -3
- package/lib/widgets/svgview.js +118 -2
- package/lib/window.js +137 -1
- package/package.json +1 -1
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
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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).
|
|
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 ->
|
|
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 `
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
96
|
+
async write(data, { selection = 'CLIPBOARD', time } = {}) {
|
|
54
97
|
await this._ensure();
|
|
55
|
-
const sel = await this._atom(selection);
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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] =
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
//
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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(
|
|
812
|
+
this.X.GetProperty(del, wid, prop, 0, 0, 0x1fffffff, (err, res) =>
|
|
244
813
|
err ? reject(err) : resolve(res)
|
|
245
814
|
)
|
|
246
815
|
);
|