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 +576 -74
- package/lib/image.js +10 -17
- package/lib/imagedata.js +388 -0
- package/lib/index.js +16 -0
- package/lib/renderingcontext_2d.js +362 -28
- package/lib/surface.js +118 -0
- package/lib/widgets/svgview.js +118 -2
- package/lib/window.js +136 -0
- 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
|
);
|
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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] =
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
//
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
|
|
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(
|
|
745
|
+
this.X.GetProperty(del, wid, prop, 0, 0, 0x1fffffff, (err, res) =>
|
|
244
746
|
err ? reject(err) : resolve(res)
|
|
245
747
|
)
|
|
246
748
|
);
|