Allow drop clients to use machine id as well

This commit is contained in:
Kovid Goyal
2026-04-12 09:27:16 +05:30
parent 88a4d90daa
commit 9ebe692bf7
4 changed files with 97 additions and 76 deletions

View File

@@ -38,7 +38,7 @@ the end of the last chunk.
Accepting drops
-----------------
In order to inform the terminal emulator that the program accepts drops, it
In order to inform the terminal emulator that the client accepts drops, it
must send the following escape code::
OSC _dnd_code ; t=a ; payload ST
@@ -48,7 +48,17 @@ The list of MIME types is optional, it is needed if the program wants to accept
exotic or private use MIME types on platforms such as macOS, where the system
does not deliver drop events unless the MIME type is registered.
When the program is done accepting drops, or at exit, it should send the escape
The terminal emulator may respond to this escape code with an escape code of
the form::
OSC _dnd_code ; t=a ; machine id ST
Here, the :ref:`machine id <machine_id>` is an id that identifies the machine
the terminal is running on and can be used by the client to determine whether
to request remote files from the terminal when a drop occurs.
See :ref:`below <machine_id>` for the semantics of the machine id.
When the client is done accepting drops, or at exit, it should send the escape
code::
OSC _dnd_code ; t=A ST
@@ -132,8 +142,12 @@ terminal emulator must then inform the OS that the drop is completed.
Dropping from remote machines
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In order to support dropping of files from remote machines,
clients can first request the :rfc:`text/uri-list <2483>` MIME
In order to support dropping of files from remote machines, the client
can use the :ref:`machine id <machine_id>` previously sent by the terminal.
If it is different from the id of the machine the client is running on, it
can choose to request remote files, as follows.
Clients can first request the :rfc:`text/uri-list <2483>` MIME
type to get a list of dropped URIs. For every URI in the list, they can
send the terminal emulator a data request of the form::

View File

@@ -144,40 +144,6 @@ reset_drop(Window *w) {
}
}
void
drop_register_window(Window *w, const uint8_t *payload, size_t payload_sz, bool on, uint32_t client_id, bool more) {
w->drop.wanted = on;
w->drop.client_id = client_id;
if (!on) { drop_free_data(w); zero_at_ptr(&w->drop); return; }
if (!payload || !payload_sz) return;
size_t sz = w->drop.registered_mimes ? strlen(w->drop.registered_mimes) : 0;
if (sz + payload_sz > MIME_LIST_SIZE_CAP) return;
w->drop.registered_mimes = realloc(w->drop.registered_mimes, sz + payload_sz + 1);
if (w->drop.registered_mimes) {
memcpy(w->drop.registered_mimes + sz, payload, payload_sz);
sz += payload_sz;
w->drop.registered_mimes[sz] = 0;
}
if (more) return;
if (w->drop.registered_mimes) {
OSWindow *osw = os_window_for_kitty_window(w->id);
if (osw) {
size_t num = 0;
RAII_ALLOC(const char*, mimes, malloc(sizeof(char*) * strlen(w->drop.registered_mimes)));
if (mimes) {
char* token = strtok(w->drop.registered_mimes, " ");
while (token != NULL) {
mimes[num++] = token;
token = strtok(NULL, " ");
}
register_mimes_for_drop(osw, mimes, num);
}
}
}
free(w->drop.registered_mimes); w->drop.registered_mimes = NULL;
}
static int
string_arrays_cmp(const char **a, size_t an, const char **b, size_t bn) {
if (an != bn) return (int)an - (int)bn;
@@ -289,6 +255,46 @@ queue_payload_to_child(id_type id, uint32_t client_id, PendingData *pending, con
if (pending->count) check_for_pending_writes();
}
void
drop_register_window(Window *w, const uint8_t *payload, size_t payload_sz, bool on, uint32_t client_id, bool more) {
w->drop.wanted = on;
w->drop.client_id = client_id;
if (!on) { drop_free_data(w); zero_at_ptr(&w->drop); return; }
if (!payload || !payload_sz) return;
size_t sz = w->drop.registered_mimes ? strlen(w->drop.registered_mimes) : 0;
if (sz + payload_sz > MIME_LIST_SIZE_CAP) return;
w->drop.registered_mimes = realloc(w->drop.registered_mimes, sz + payload_sz + 1);
if (w->drop.registered_mimes) {
memcpy(w->drop.registered_mimes + sz, payload, payload_sz);
sz += payload_sz;
w->drop.registered_mimes[sz] = 0;
}
if (more) return;
if (w->drop.registered_mimes) {
OSWindow *osw = os_window_for_kitty_window(w->id);
if (osw) {
size_t num = 0;
RAII_ALLOC(const char*, mimes, malloc(sizeof(char*) * strlen(w->drop.registered_mimes)));
if (mimes) {
char* token = strtok(w->drop.registered_mimes, " ");
while (token != NULL) {
mimes[num++] = token;
token = strtok(NULL, " ");
}
register_mimes_for_drop(osw, mimes, num);
}
}
}
free(w->drop.registered_mimes); w->drop.registered_mimes = NULL;
const char* host_machine_id = machine_id();
if (host_machine_id) {
char header[32] = {0};
int n = snprintf(header, sizeof(header), "\x1b]%d;t=a", DND_CODE);
queue_payload_to_child(
w->id, w->drop.client_id, &w->drop.pending, header, n, host_machine_id, strlen(host_machine_id), false);
}
}
void
drop_move_on_child(Window *w, const char** mimes, size_t num_mimes, bool is_drop) {
if (!w->drop.hovered) {
@@ -866,13 +872,6 @@ do_drop_request_uri_data(Window *w, int32_t mime_idx, int32_t file_idx) {
return sync;
}
void
drop_request_uri_data(Window *w, const char *payload, size_t payload_sz) {
(void)w; (void)payload; (void)payload_sz;
/* This function is no longer used in the new protocol; URI data requests
* are now handled through the unified t=r queue via do_drop_request_uri_data. */
}
/* Handle a directory request from the client.
* handle_id: the directory handle (Y= key).
* entry_num: 0 means close the handle; >=1 means read that entry (x= key, 1-based).

View File

@@ -14,7 +14,6 @@ void drop_move_on_child(Window *w, const char **mimes, size_t num_mimes, bool is
void drop_left_child(Window *w);
void drop_free_data(Window *w);
void drop_send_einval(Window *w);
void drop_request_uri_data(Window *w, const char *payload, size_t payload_sz);
void drop_handle_dir_request(Window *w, uint32_t handle_id, int32_t entry_num);
void drop_enqueue_request(Window *w, int32_t cell_x, int32_t cell_y, int32_t pixel_y);
void drop_set_status(Window *w, int operation, const char *payload, size_t payload_sz, bool more);

View File

@@ -5,6 +5,7 @@ import errno
import re
from base64 import standard_b64decode, standard_b64encode
from contextlib import contextmanager
from functools import partial
from kitty.fast_data_types import (
DND_CODE,
@@ -16,6 +17,7 @@ from kitty.fast_data_types import (
dnd_test_fake_drop_event,
dnd_test_set_mouse_pos,
)
from kitty.machine_id import machine_id
from . import BaseTest, parse_bytes
@@ -321,6 +323,8 @@ def dnd_test_window():
dnd_test_cleanup_fake_window(os_window_id)
machine_id = partial(machine_id, 'tty-dnd-protocol-machine-id')
# ---- test class -------------------------------------------------------------
class TestDnDProtocol(BaseTest):
@@ -328,6 +332,14 @@ class TestDnDProtocol(BaseTest):
def _assert_no_output(self, capture: _WriteCapture, window_id: int) -> None:
self.ae(capture.peek(window_id), b'', 'unexpected output to child')
def _register_for_drops(self, screen, cap, wid, mimes='text/plain text/uri-list', client_id=0) -> None:
parse_bytes(screen, client_register(mimes, client_id=client_id))
events = self._get_events(cap, wid)
self.assertEqual(len(events), 1, events)
self.ae(events[0]['type'], 'a')
self.ae(events[0]['payload'].strip().decode(), machine_id())
def _get_events(self, capture: _WriteCapture, window_id: int) -> list[dict]:
return parse_escape_codes(capture.consume(window_id))
@@ -336,10 +348,7 @@ class TestDnDProtocol(BaseTest):
with dnd_test_window() as (osw, wid, screen, cap):
# Client registers state is already wanted=True from fake-window creation,
# but calling the escape code should not break things.
parse_bytes(screen, client_register('text/plain text/uri-list'))
# No output expected at this point (no drop in progress).
self._assert_no_output(cap, wid)
self._register_for_drops(screen, cap, wid)
# Client unregisters.
parse_bytes(screen, client_unregister())
self._assert_no_output(cap, wid)
@@ -347,7 +356,7 @@ class TestDnDProtocol(BaseTest):
def test_drop_move_sends_move_event(self) -> None:
"""A drop entering and moving over the window generates t=m events."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 5, 3, 100, 60)
dnd_test_fake_drop_event(wid, False, ['text/plain', 'text/uri-list'])
@@ -366,7 +375,7 @@ class TestDnDProtocol(BaseTest):
def test_drop_move_mime_always_sent(self) -> None:
"""The current implementation always includes the MIME list in move events."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
mimes = ['text/plain']
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, False, mimes)
@@ -384,7 +393,7 @@ class TestDnDProtocol(BaseTest):
def test_drop_leave_sends_leave_event(self) -> None:
"""Drop leaving sends t=m with x=-1,y=-1."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, False, ['text/plain'])
cap.consume(wid)
@@ -400,7 +409,7 @@ class TestDnDProtocol(BaseTest):
def test_client_accepts_drop(self) -> None:
"""Client sending t=m:o=1 is recorded and does not trigger extra output."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, False, ['text/plain'])
cap.consume(wid)
@@ -414,7 +423,7 @@ class TestDnDProtocol(BaseTest):
"""Complete happy-path: move → accept → drop → request → data → finish."""
payload_data = b'hello world'
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
# Move
dnd_test_set_mouse_pos(wid, 2, 3, 16, 24)
@@ -451,7 +460,7 @@ class TestDnDProtocol(BaseTest):
def test_request_unknown_mime(self) -> None:
"""Requesting an out-of-range MIME index yields an error."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -466,7 +475,7 @@ class TestDnDProtocol(BaseTest):
def test_data_error_propagation(self) -> None:
"""When data retrieval fails the client receives a t=R error code."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -483,7 +492,7 @@ class TestDnDProtocol(BaseTest):
def test_data_eperm_error(self) -> None:
"""EPERM error is correctly forwarded to the client."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -501,7 +510,7 @@ class TestDnDProtocol(BaseTest):
chunk_limit = 3072
big_payload = b'X' * (chunk_limit * 3) # 3 chunks expected
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -519,7 +528,7 @@ class TestDnDProtocol(BaseTest):
"""The client_id (i=…) set during registration is echoed in all replies."""
client_id = 42
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain', client_id=client_id))
self._register_for_drops(screen, cap, wid, mimes='text/plain', client_id=client_id)
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, False, ['text/plain'])
raw = cap.consume(wid)
@@ -530,7 +539,7 @@ class TestDnDProtocol(BaseTest):
def test_multiple_mimes_priority(self) -> None:
"""The client can request data from any offered MIME type by index."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain text/uri-list'))
self._register_for_drops(screen, cap, wid, 'text/plain text/uri-list')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
# OS offers both types.
dnd_test_fake_drop_event(wid, True, ['text/plain', 'text/uri-list'])
@@ -569,7 +578,7 @@ class TestDnDProtocol(BaseTest):
def test_move_event_after_mime_change(self) -> None:
"""When offered MIME list changes, the new list is included in the move event."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, False, ['text/plain'])
cap.consume(wid)
@@ -585,7 +594,7 @@ class TestDnDProtocol(BaseTest):
def test_drop_event_has_uppercase_M(self) -> None:
"""A drop (not just a move) sends t=M (uppercase)."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
events = self._get_events(cap, wid)
@@ -595,7 +604,7 @@ class TestDnDProtocol(BaseTest):
def test_data_end_signal(self) -> None:
"""The end-of-data signal is an empty payload escape code."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -613,7 +622,7 @@ class TestDnDProtocol(BaseTest):
def test_empty_data(self) -> None:
"""Zero-byte payload is handled gracefully only end signal is sent."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -633,7 +642,7 @@ class TestDnDProtocol(BaseTest):
"""Register, drop, deliver text/uri-list data, discard move/drop events."""
if mimes is None:
mimes = ['text/plain', 'text/uri-list']
parse_bytes(screen, client_register('text/plain text/uri-list'))
self._register_for_drops(screen, cap, wid, 'text/plain text/uri-list')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, mimes)
cap.consume(wid)
@@ -728,7 +737,7 @@ class TestDnDProtocol(BaseTest):
fpath = f.name
try:
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain', 'text/uri-list'])
cap.consume(wid)
@@ -1826,7 +1835,7 @@ class TestDnDProtocol(BaseTest):
"""x= key is echoed in data responses to identify which request is being answered."""
payload_data = b'hello disambiguation'
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -1843,7 +1852,7 @@ class TestDnDProtocol(BaseTest):
def test_x_key_echoed_in_error_response(self) -> None:
"""x= key is echoed in error responses."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -1859,7 +1868,7 @@ class TestDnDProtocol(BaseTest):
def test_x_key_in_error_for_io_failure(self) -> None:
"""x= key is echoed in I/O error responses."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -1875,7 +1884,7 @@ class TestDnDProtocol(BaseTest):
def test_fifo_order_with_different_indices(self) -> None:
"""Multiple requests with different x= values are served in FIFO order."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain text/html'))
self._register_for_drops(screen, cap, wid, 'text/plain text/html')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain', 'text/html'])
cap.consume(wid)
@@ -1905,7 +1914,7 @@ class TestDnDProtocol(BaseTest):
def test_request_after_error_proceeds(self) -> None:
"""After an error response, the next queued request is processed."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -1934,7 +1943,7 @@ class TestDnDProtocol(BaseTest):
def test_queue_overflow_returns_emfile(self) -> None:
"""Exceeding 128 queued requests returns EMFILE and ends the drop."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -2118,7 +2127,7 @@ class TestDnDProtocol(BaseTest):
def test_finish_after_queued_requests(self) -> None:
"""A finish (empty t=r) after queued requests processes remaining then finishes."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -2139,7 +2148,7 @@ class TestDnDProtocol(BaseTest):
def test_multiple_sync_errors_processed_immediately(self) -> None:
"""Multiple queued requests that all fail synchronously are processed immediately."""
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)
@@ -2164,7 +2173,7 @@ class TestDnDProtocol(BaseTest):
"""Responses must not contain the old r= key."""
payload_data = b'no r= key test'
with dnd_test_window() as (osw, wid, screen, cap):
parse_bytes(screen, client_register('text/plain'))
self._register_for_drops(screen, cap, wid, 'text/plain')
dnd_test_set_mouse_pos(wid, 2, 3, 16, 24)
dnd_test_fake_drop_event(wid, True, ['text/plain'])
cap.consume(wid)