diff --git a/docs/dnd-protocol.rst b/docs/dnd-protocol.rst index 706dc3298..94ef6e00f 100644 --- a/docs/dnd-protocol.rst +++ b/docs/dnd-protocol.rst @@ -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 ` 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. diff --git a/gen/apc_parsers.py b/gen/apc_parsers.py index 1ab1076fc..66ba7092e 100755 --- a/gen/apc_parsers.py +++ b/gen/apc_parsers.py @@ -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'), diff --git a/kitty/dnd.c b/kitty/dnd.c index 4f254b3c4..baf2f13bd 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -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 diff --git a/kitty/dnd.h b/kitty/dnd.h index bf33f1792..138cb5c27 100644 --- a/kitty/dnd.h +++ b/kitty/dnd.h @@ -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); diff --git a/kitty/parse-dnd-command.h b/kitty/parse-dnd-command.h index cd3262a51..70ae1f3c9 100644 --- a/kitty/parse-dnd-command.h +++ b/kitty/parse-dnd-command.h @@ -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); diff --git a/kitty/screen.c b/kitty/screen.c index 0a6a1d628..4fd371b66 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -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; } } diff --git a/kitty/state.c b/kitty/state.c index e065fb9ec..507ccca16 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -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); diff --git a/kitty/state.h b/kitty/state.h index a51c898b6..d03d28cce 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -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; diff --git a/kitty_tests/dnd.py b/kitty_tests/dnd.py index ae1a72daf..086c1a8fc 100644 --- a/kitty_tests/dnd.py +++ b/kitty_tests/dnd.py @@ -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)