Clipboard
app.clipboard transfers data between X clients through selections —
lazily created on first use, like app.fonts.
X has no clipboard buffer: "copy" means owning a selection (the
CLIPBOARD atom for explicit copy/paste, PRIMARY for middle-click paste)
and answering conversion requests from whichever client pastes; "paste"
means asking the current owner to convert its data into a property and
reading it back. app.clipboard hides that ICCCM dance behind promises,
using a hidden 1×1 never-mapped helper window as the selection endpoint.
import { createClient } from 'ntk';
const app = await createClient();
await app.clipboard.write('Hello, κόσμε!');
const text = await app.clipboard.read(); // from whoever owns CLIPBOARD now
app.clipboard.write(data, [options]) → Promise
Takes ownership of a selection and serves data to anyone who pastes.
-
data— a string, or an object (orMap) from target name to payload for multi-format ownership:await app.clipboard.write({'text/plain;charset=utf-8': 'hello','text/html': '<b>hello</b>','image/png': pngBuffer});String payloads are encoded UTF-8 (latin-1 for the
STRINGtarget, which is defined as latin-1);Buffers and typed arrays are served as-is, so the target name is the only thing that says what the bytes mean. A bare string is shorthand for offeringUTF8_STRINGandSTRING. -
options.selection— selection atom name, default'CLIPBOARD'; use'PRIMARY'for the middle-click paste buffer. -
options.time— the server timestamp of the event that triggered the copy (thetimefield of the key or button event you handled). ICCCM 2.1 requires ownership to be taken with such a timestamp, and it is what arbitrates a race with another client copying at the same moment. Without one, ntk asks the server for the current time rather than usingCurrentTime, which ICCCM forbids. -
Resolves once the server confirms the ownership; rejects if it could not be acquired.
-
Ownership (and the data) is held until another client copies — the server's
SelectionClearthen drops it — untilclear()gives it up, or until the app closes. The data lives in this process: unlike desktops with a clipboard manager, closing the app makes the selection unavailable, which is normal X behavior.
Requestors are offered the three targets ICCCM 2.6.2 makes mandatory —
TARGETS, TIMESTAMP (the ownership timestamp, as an INTEGER) and
MULTIPLE (a batch of conversions listed in an ATOM_PAIR property; pairs
that cannot be converted come back with their property set to None) —
plus whatever the call offered, in the order it offered them. Because ntk
answers those three itself, they cannot be used as target names in data.
Any other target is refused with a SelectionNotify carrying property
None.
Payloads too large for a single X request are transferred incrementally
(INCR, ICCCM 2.7.2): the conversion is answered with an INCR property
holding the byte count, and the chunks follow as the requestor consumes
them. This is transparent to both sides — nothing in the API changes with
size. A requestor that abandons a transfer is dropped after 10 seconds of
silence.
app.clipboard.clear([selection]) → Promise
Gives a selection back: stops owning it, and stops answering for it.
await app.clipboard.clear(); // CLIPBOARD
await app.clipboard.clear('XdndSelection'); // at the end of a drag
The counterpart to write(), which otherwise holds a selection until another
client takes it or the app exits. Two things want it: a drag source, which
should stop offering its payload once the drag is over — a stale offer
answered afterwards is what XDND's "throw out extremely old data" rule is
about — and apps that clear the clipboard deliberately, a password manager
being the usual example.
selection— selection atom name, default'CLIPBOARD'. One selection at a time: clearingCLIPBOARDleavesPRIMARYalone.- Clearing a selection this app does not own does nothing, and in particular
sends nothing.
SetSelectionOwner(None)from a non-owner would take the selection away from whichever client legitimately holds it. - Released with the timestamp the selection was acquired with, as ICCCM 2.3.1 requires — not a fresh one. The server ignores a release whose time is earlier than the selection's current last-change time, so if another client has taken the selection meanwhile this correctly does nothing, where a fresh timestamp would have taken it away from them.
- A transfer already in flight still completes: an
INCRtransfer holds its own copy of the payload (ICCCM 2.7.2), exactly as when another client takes the selection from us.
app.clipboard.read([options]) → Promise<string>
Reads the current text of a selection from whoever owns it.
options.selection— as above, default'CLIPBOARD'.options.timeout— ms to wait for the owner at each protocol step, default 2000.options.time— the server timestamp of the event that asked for the paste (thetimefield of the key or button event you handled). ICCCM 2.4 says to convert with it rather thanCurrentTime, so that an owner which has replaced its data since can tell the request is for the older value. Omitting it meansCurrentTime, which every mainstream owner accepts and a strict one may not.- Conversion is requested as
UTF8_STRINGfirst; if the owner refuses (old Xt/Motif apps), it is retried once as latin-1STRING. The retry is part of the same paste, so it carries the same timestamp. - Incremental (
INCR) transfers are followed transparently, so pasting data larger than the server's single-transfer limit works. - Rejects with a descriptive error when the selection has no owner, when
the owner supports neither text target, or when the owner stops
responding within
timeout.
Concurrent read() calls on one app are serialized internally (they share
one transfer property on the helper window).
Reading a specific target
options.target reads one named target instead of text, and resolves with a
Buffer — the mirror of offering one to write():
const png = await app.clipboard.read({ target: 'image/png' });
const html = await app.clipboard.read({ target: 'text/html' });
html.toString('utf8');
INCR applies here too, so a payload larger than one request reassembles
transparently. Rejects naming the target when the owner cannot convert to it.
app.clipboard.watch(selection, handler) → Promise<function>
Call handler whenever a selection changes hands — the event an edit menu
needs to grey out Paste, and what a clipboard-manager-style tool is built on.
const unwatch = await app.clipboard.watch('CLIPBOARD', (ev) => {
pasteItem.disabled = ev.owner === 0;
});
unwatch(); // stop listening
The handler gets:
| field | |
|---|---|
selection | the name you asked for, e.g. 'CLIPBOARD' |
owner | window now owning it, or 0 when it is unowned |
timestamp | server time of the change |
selectionTimestamp | time the current owner acquired it |
reason | 'new-owner', 'destroyed' or 'closed' |
'destroyed' means the owning window went away, 'closed' that the owning
client disconnected. Both usually leave owner at 0, which is the case
worth acting on: there is nothing to paste.
The alternative is polling read(), which is a full conversion round trip
against whatever foreign client owns the selection — and a two second wait
when that client is wedged. This is a server-side subscription instead, so it
costs nothing until something changes.
Watchers share one server-side registration per selection: watching
CLIPBOARD twice is one extra callback and no extra protocol, and the
registration is dropped only when the last watcher for that selection
unsubscribes. unwatch() is idempotent. A handler that throws is reported
with console.warn and does not cost the other watchers their event.
Built on XFixes SelectSelectionInput. Every X server since about 2004 has
the extension; on one that does not, watch() rejects saying so, rather than
silently never firing.
app.clipboard.targets([options]) → Promise<string[]>
What the current owner can convert to, as target names.
const offered = await app.clipboard.targets();
if (offered.includes('image/png')) {
const png = await app.clipboard.read({ target: 'image/png' });
}
Ask this before read({ target }): an owner answers TARGETS cheaply, where
guessing costs a failed conversion per guess. Resolves with [] when nothing
owns the selection.
Takes options.selection, options.timeout and options.time, all with the
same meaning as in read().
Limitations
- Nothing negotiates with a clipboard manager (
SAVE_TARGETS), so the data really does vanish when the app exits. - The
STRINGtarget is latin-1 by definition — codepoints above U+00FF are lossy there. Modern requestors ask forUTF8_STRING, which is not.
Testing without a display
The whole protocol runs against node-x11's in-process JS X server (which
routes SetSelectionOwner / ConvertSelection / SendEvent since x11
3.1.0) — see xserver.md and test/clipboard.test.js, where
two ntk clients and raw node-x11 clients (a STRING-only legacy owner, an
INCR owner, requestors asking for TARGETS, TIMESTAMP, MULTIPLE and
INCR payloads, and owners that record the timestamp each conversion
arrives with) pass data to each other hermetically.