Add machine id and stub for t=k transfers

This commit is contained in:
Kovid Goyal 2026-04-11 17:20:53 +05:30
parent cfa9f1bada
commit df20d4aa7c
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
9 changed files with 97 additions and 38 deletions

View file

@ -232,10 +232,12 @@ Terminal programs can inform the terminal emulator that they
are willing to act as a source of drag data by sending the
sending the escape code::
OSC _dnd_code ; t=o ST
OSC _dnd_code ; t=o:x=1 ; optional machine id ST
On exit, or if the program no longer is willing to start drag gestures, it must
send ``t=O`` to the terminal to indicate it no longer wants to offer drag data.
send ``t=o:x=2`` to the terminal to indicate it no longer wants to offer drag data.
The ``machine id`` is optional and is used to enable dragging from remote
machines. See :ref:`below <machine_id>` for its semantics.
When the user performs the platform specific gesture to start a drag operation,
the terminal will send the same escape code back to the terminal program
@ -356,7 +358,9 @@ Dragging to remote machines
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To support dragging files to remote machines, when requesting the data for the
``text/uri-list`` MIME type, terminal emulators can add the ``Y=1`` key. On
``text/uri-list`` MIME type, terminal emulators can add the ``Y=1`` key.
Terminals can examine the :ref:`machine_id` sent with the enable drag offers
``t=o`` escape code to decide whether to use ``Y=1`` or not. On
receipt of this key, the client should first send the ``text/uri-list`` as
normal and then a series of responses for every ``file://`` URL type in the
list of the form::
@ -366,12 +370,12 @@ list of the form::
OSC _dnd_code ; t=k:x=idx:X=handle:m=0 or 1 ; base64 encoded null separated list of directory entries ST
These represent possibly chunked data for files, symlinks and directories, as
denoted by the ``X`` key. As always, end of data is indicated by an escape code
with ``m=0`` and no payload. ``idx`` is the one based index into the list of
entries in the ``text/uri-list`` MIME type. ``file://`` URLs that point to
symlinks must be resolved to files or directories and sent. So actual symlinks
will appear only when recursing through directories as described below. Only
regular files should be sent.
denoted by the ``X`` key. As above, end of data for an individual entry is
indicated by an escape code with ``m=0`` and no payload. ``idx`` is the one
based index into the list of entries in the ``text/uri-list`` MIME type.
``file://`` URLs that point to symlinks must be resolved to files or
directories and sent. So actual symlinks will appear only when recursing
through directories as described below. Only regular files should be sent.
Terminals should write the transmitted data into a temporary directory
and replace the entries in the ``text/uri-list`` data with the transmitted
@ -384,13 +388,17 @@ that serves as an identifier for the directory. Directories must be traversed
in breadth first order. The children of a directory are reported by
adding ``Y=parent-handle:y=num`` to the escape codes above. Here
``parent-handle`` is the handle of the directory being traversed and ``num``
is the one based index into the list of entries in the directory.
is the one based index into the list of entries in the directory. Thus, the
set of keys ``x, y, Y`` uniquely determine an entry.
Once all data is transmitted, the client informs the terminal emulator of
completion with::
OSC _dnd_code ; t=k ; ST
At this point, the terminal should send the modified data for ``text/uri-list``
to the drop destination.
If any error occurs in the client while reading the data, it can inform
the terminal using::
@ -432,8 +440,7 @@ Key Value Default Description
``M`` - a drop dropped event
``r`` - request dropped data
``R`` - report an error
``o`` - start offering drags
``O`` - stop offering drags
``o`` - start offering drags or start a drag
``p`` - present data for drag offers
``P`` - Change drag image or start drag
``e`` - a drag offer event occurred
@ -460,4 +467,16 @@ Key Value Default Description
======= ==================== ========= =================
.. _machine_id:
Machine id
-----------------
The machine id is used to detect when a drag is started on a remote machine. It
is of the form: ``version:ASCII printable chars``. The leading ``version`` field
allows for changing the format or semantics of this field in the future. The
actual id is the machine id (the contents of :file:`/etc/machine-id` on
Linux/BSD and :file:`HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography\\MachineGuid` on Windows and ``IOPlatformUUID`` on macOS). This machine id is then hashed using a :rfc:`HMAC <2104>`
with :rfc:`SHA-256 <6234>` as the digest algorithm and the key being the ASCII bytes:
``tty-dnd-protocol-machine-id``. The hashing is done so as to not easily leak the
actual machine id and to ensure that the value is of fixed size.

View file

@ -333,7 +333,7 @@ def parsers() -> None:
write_header(text, 'kitty/parse-multicell-command.h')
keymap = {
't': ('type', flag('aAmMrRoOpPeEk')),
't': ('type', flag('aAmMrRopPeEk')),
'm': ('more', 'uint'),
'i': ('client_id', 'uint'),
'o': ('operation', 'uint'),

View file

@ -1067,6 +1067,7 @@ drag_free_built_data(Window *w) {
free(ds.items);
ds.items = NULL;
}
ds.num_mimes = 0;
for (size_t i = 0; i < arraysz(ds.images); i++) {
if (ds.images[i].data) free(ds.images[i].data);
zero_at_ptr(ds.images + i);
@ -1075,11 +1076,10 @@ drag_free_built_data(Window *w) {
void
drag_free_offer(Window *w) {
free(ds.mimes_buf); ds.mimes_buf = NULL;
free(ds.mimes_buf); ds.mimes_buf = NULL; ds.bufsz = 0;
drag_free_built_data(w);
ds.allowed_operations = 0;
ds.state = DRAG_SOURCE_NONE;
ds.num_mimes = 0;
ds.pre_sent_total_sz = 0;
ds.images_sent_total_sz = 0;
}
@ -1100,12 +1100,31 @@ cancel_drag(Window *w, int error_code) {
drag_free_offer(w);
}
#define abrt(code) { cancel_drag(w, code); return; }
void
drag_start_offerring(Window *w, const char *client_machine_id, size_t sz) {
ds.can_offer = true; ds.is_remote_client = false;
if (sz && client_machine_id) {
const char *host_machine_id = machine_id();
if (host_machine_id) {
size_t hsz = strlen(host_machine_id);
if (hsz != sz || memcmp(host_machine_id, client_machine_id, sz) != 0) ds.is_remote_client = true;
}
}
}
void
drag_stop_offerring(Window *w) {
drag_free_offer(w);
ds.can_offer = false; ds.is_remote_client = false;
}
void
drag_add_mimes(Window *w, int allowed_operations, uint32_t client_id, const char *data, size_t sz, bool has_more) {
#define abrt(code) { cancel_drag(w, code); return; }
if (allowed_operations && ds.state != DRAG_SOURCE_NONE) cancel_drag(w, 0);
if (!ds.can_offer) abrt(EINVAL);
if (allowed_operations && !ds.allowed_operations) ds.allowed_operations = allowed_operations;
if (!ds.allowed_operations) { abrt(EINVAL); }
if (!ds.allowed_operations || ds.state > DRAG_SOURCE_BEING_BUILT) abrt(EINVAL);
ds.state = DRAG_SOURCE_BEING_BUILT;
ds.client_id = client_id;
size_t new_sz = ds.bufsz + sz;
@ -1348,7 +1367,8 @@ drag_get_data(Window *w, const char *mime_type, size_t *sz, int *err_code) {
}
// No fd yet, request data from the client
char buf[128];
int header_sz = snprintf(buf, sizeof(buf), "\x1b]%d;t=e:x=%d:y=%zu", DND_CODE, DRAG_NOTIFY_FINISHED + 2, i);
int header_sz = snprintf(buf, sizeof(buf), "\x1b]%d;t=e:x=%d:y=%zu:Y=%d",
DND_CODE, DRAG_NOTIFY_FINISHED + 2, i, ds.is_remote_client);
queue_payload_to_child(w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_sz, NULL, 0, false);
*err_code = EAGAIN;
return NULL;
@ -1449,6 +1469,14 @@ drag_process_item_data(Window *w, size_t idx, int has_more, const uint8_t *paylo
if (ret) cancel_drag(w, ret);
}
}
void
drag_remote_file_data(
Window *w, int32_t x, int32_t y, int32_t X, int32_t Y, bool has_more, const uint8_t *payload, size_t payload_sz
) {
(void)w; (void)x; (void)y; (void)X; (void)Y; (void)has_more; (void)payload; (void)payload_sz;
// TODO: Implement this
}
#undef img
#undef abrt
#undef ds

View file

@ -35,3 +35,6 @@ void drag_notify(Window *w, DragNotifyType type);
int drag_free_data(Window *w, const char *mime_type, const char* data, size_t sz);
const char* drag_get_data(Window *w, const char *mime_type, size_t *sz, int *err_code);
void drag_process_item_data(Window *w, size_t idx, int has_more, const uint8_t *payload, size_t payload_sz);
void drag_remote_file_data(Window *w, int32_t x, int32_t y, int32_t X, int32_t Y, bool has_more, const uint8_t *payload, size_t payload_sz);
void drag_start_offerring(Window *w, const char *client_machine_id, size_t sz);
void drag_stop_offerring(Window *w);

View file

@ -86,10 +86,9 @@ static inline void parse_dnd_code(PS *self, uint8_t *parser_buf,
case type: {
g.type = parser_buf[pos++];
if (g.type != 'A' && g.type != 'E' && g.type != 'M' && g.type != 'O' &&
g.type != 'P' && g.type != 'R' && g.type != 'a' && g.type != 'e' &&
g.type != 'k' && g.type != 'm' && g.type != 'o' && g.type != 'p' &&
g.type != 'r') {
if (g.type != 'A' && g.type != 'E' && g.type != 'M' && g.type != 'P' &&
g.type != 'R' && g.type != 'a' && g.type != 'e' && g.type != 'k' &&
g.type != 'm' && g.type != 'o' && g.type != 'p' && g.type != 'r') {
REPORT_ERROR("Malformed DnDCommand control block, unknown flag value "
"for type: 0x%x",
g.type);

View file

@ -1535,14 +1535,13 @@ screen_handle_dnd_command(Screen *self, const DnDCommand *cmd, const uint8_t *pa
drop_enqueue_request(w, cmd->cell_x, cmd->cell_y, cmd->pixel_y);
} break;
case 'o': {
if (cmd->payload_sz > 0) drag_add_mimes(w, (int)cmd->operation, cmd->client_id, (const char*)payload, cmd->payload_sz, cmd->more);
else w->drag_source.can_offer = true;
} break;
case 'O': {
drag_free_offer(w);
w->drag_source.can_offer = false;
if (global_state.drag_source.is_active && global_state.drag_source.from_window == w->id) {
cancel_current_drag_source();
switch (cmd->cell_x) {
case 1: drag_start_offerring(w, (const char*)payload, cmd->payload_sz); break;
case 2: drag_stop_offerring(w); break;
case 0:
drag_add_mimes(
w, (int)cmd->operation, cmd->client_id, (const char*)payload, cmd->payload_sz, cmd->more);
break;
}
} break;
case 'p': {
@ -1564,6 +1563,10 @@ screen_handle_dnd_command(Screen *self, const DnDCommand *cmd, const uint8_t *pa
}
} else drag_process_item_data(w, cmd->cell_y, -1, payload, cmd->payload_sz);
} break;
case 'k': {
drag_remote_file_data(
w, cmd->cell_x, cmd->cell_y, cmd->pixel_x, cmd->pixel_y, cmd->more != 0, payload, cmd->payload_sz);
} break;
}
}

View file

@ -353,6 +353,7 @@ update_os_window_title(OSWindow *os_window) {
static void
destroy_window(Window *w) {
drop_free_data(w);
drag_free_offer(w);
free(w->pending_clicks.clicks); zero_at_ptr(&w->pending_clicks);
free(w->buffered_keys.key_data); zero_at_ptr(&w->buffered_keys);
Py_CLEAR(w->render_data.screen); Py_CLEAR(w->title);

View file

@ -296,7 +296,7 @@ typedef struct Window {
int32_t current_request_x, current_request_y, current_request_Y;
} drop;
struct {
bool can_offer;
bool can_offer, is_remote_client;
struct { index_type x, y; bool active; } potential_url_drag;
struct { double x, y; monotonic_t at; } initial_left_press;
char *mimes_buf; size_t num_mimes, bufsz;

View file

@ -93,7 +93,7 @@ def client_dir_read(handle_id: int, entry_num: int | None = None, client_id: int
def client_drag_register(client_id: int = 0) -> bytes:
"""Escape code a client sends to start offering drags (t=o, no payload)."""
meta = f'{DND_CODE};t=o'
meta = f'{DND_CODE};t=o:x=1'
if client_id:
meta += f':i={client_id}'
return _osc(meta)
@ -101,7 +101,7 @@ def client_drag_register(client_id: int = 0) -> bytes:
def client_drag_unregister(client_id: int = 0) -> bytes:
"""Escape code a client sends to stop offering drags (t=O)."""
meta = f'{DND_CODE};t=O'
meta = f'{DND_CODE};t=o:x=2'
if client_id:
meta += f':i={client_id}'
return _osc(meta)
@ -1303,6 +1303,7 @@ class TestDnDProtocol(BaseTest):
def _setup_drag_offer(self, screen, wid, cap, mimes: str = 'text/plain', operations: int = 1, client_id: int = 0):
"""Send t=o with operations and payload to set up a drag offer being built."""
parse_bytes(screen, client_drag_register())
parse_bytes(screen, client_drag_offer_mimes(operations, mimes, client_id=client_id))
cap.consume(wid) # discard any output
@ -1320,6 +1321,7 @@ class TestDnDProtocol(BaseTest):
def test_drag_offer_single_mime(self) -> None:
"""Client can offer a drag with a single MIME type."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_drag_register())
parse_bytes(screen, client_drag_offer_mimes(1, 'text/plain'))
# No error expected the offer is being built.
self._assert_no_output(cap, wid)
@ -1327,35 +1329,37 @@ class TestDnDProtocol(BaseTest):
def test_drag_offer_multiple_mimes(self) -> None:
"""Client can offer a drag with multiple MIME types."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_drag_register())
parse_bytes(screen, client_drag_offer_mimes(3, 'text/plain text/uri-list application/json'))
self._assert_no_output(cap, wid)
def test_drag_offer_no_operations_returns_einval(self) -> None:
"""Offering MIME types with operations=0 (no valid operations) returns EINVAL."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_drag_register())
# First need a valid offer to set allowed_operations, but if we pass o=0
# directly and there's no prior offer, drag_add_mimes should abort with EINVAL.
parse_bytes(screen, client_drag_offer_mimes(0, 'text/plain'))
events = self._get_events(cap, wid)
self.assertEqual(len(events), 1, events)
self.ae(events[0]['type'], 'E')
self.ae(events[0]['payload'].strip(), b'EINVAL')
self.assert_error(cap, wid)
def test_drag_offer_copy_only(self) -> None:
"""Offering with operations=1 (copy only) is accepted."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_drag_register())
parse_bytes(screen, client_drag_offer_mimes(1, 'text/plain'))
self._assert_no_output(cap, wid)
def test_drag_offer_move_only(self) -> None:
"""Offering with operations=2 (move only) is accepted."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_drag_register())
parse_bytes(screen, client_drag_offer_mimes(2, 'text/plain'))
self._assert_no_output(cap, wid)
def test_drag_offer_copy_and_move(self) -> None:
"""Offering with operations=3 (copy+move) is accepted."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_drag_register())
parse_bytes(screen, client_drag_offer_mimes(3, 'text/plain text/html'))
self._assert_no_output(cap, wid)
@ -1556,6 +1560,7 @@ class TestDnDProtocol(BaseTest):
"""The client_id (i=…) set during drag offer is echoed in error replies."""
client_id = 99
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_drag_register())
parse_bytes(screen, client_drag_offer_mimes(1, 'text/plain', client_id=client_id))
self._assert_no_output(cap, wid)
# Starting the drag will fail (no real window), producing an error with client_id
@ -1582,6 +1587,7 @@ class TestDnDProtocol(BaseTest):
"""A large MIME list can be sent in chunks using m=1."""
with dnd_test_window() as (osw, wid, screen, cap):
# First chunk with m=1 (more coming)
parse_bytes(screen, client_drag_register())
parse_bytes(screen, client_drag_offer_mimes(1, 'text/plain ', more=True))
self._assert_no_output(cap, wid)