mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-10-22 21:23:44 +00:00
Merge branch 'copilot/implement-dnd-protocol-dropping-remote' of https://github.com/kovidgoyal/kitty
This commit is contained in:
commit
05f4c324f1
5 changed files with 799 additions and 0 deletions
414
kitty/dnd.c
414
kitty/dnd.c
|
|
@ -9,6 +9,12 @@
|
|||
#include "base64.h"
|
||||
#include "control-codes.h"
|
||||
#include "iqsort.h"
|
||||
#include <dirent.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
|
||||
// In test mode, this callable is invoked instead of schedule_write_to_child_if_possible.
|
||||
// It receives (window_id: int, data: bytes) and its return value is ignored.
|
||||
|
|
@ -45,6 +51,25 @@ free_pending(PendingData *pending) {
|
|||
zero_at_ptr(pending);
|
||||
}
|
||||
|
||||
static void
|
||||
drop_free_dir_handle(DirHandle *h) {
|
||||
free(h->path);
|
||||
for (size_t i = 0; i < h->num_entries; i++) free(h->entries[i]);
|
||||
free(h->entries);
|
||||
zero_at_ptr(h);
|
||||
}
|
||||
|
||||
static void
|
||||
drop_free_dir_handles(Window *w) {
|
||||
for (size_t i = 0; i < w->drop.num_dir_handles; i++)
|
||||
drop_free_dir_handle(&w->drop.dir_handles[i]);
|
||||
free(w->drop.dir_handles);
|
||||
w->drop.dir_handles = NULL;
|
||||
w->drop.num_dir_handles = 0;
|
||||
w->drop.dir_handles_capacity = 0;
|
||||
w->drop.next_dir_handle_id = 0;
|
||||
}
|
||||
|
||||
void
|
||||
drop_free_data(Window *w) {
|
||||
drop_free_offered_mimes(w);
|
||||
|
|
@ -53,6 +78,7 @@ drop_free_data(Window *w) {
|
|||
free(w->drop.registered_mimes); w->drop.registered_mimes = NULL;
|
||||
free(w->drop.uri_list); w->drop.uri_list = NULL;
|
||||
free(w->drop.getting_data_for_mime); w->drop.getting_data_for_mime = NULL;
|
||||
drop_free_dir_handles(w);
|
||||
}
|
||||
|
||||
static void
|
||||
|
|
@ -325,6 +351,8 @@ get_errno_name(int err) {
|
|||
case EPERM: return "EPERM";
|
||||
case ENOENT: return "ENOENT";
|
||||
case EIO: return "EIO";
|
||||
case EINVAL: return "EINVAL";
|
||||
case ENOMEM: return "ENOMEM";
|
||||
default: return "EUNKNOWN";
|
||||
}
|
||||
}
|
||||
|
|
@ -337,6 +365,11 @@ drop_send_error(Window *w, int error_code) {
|
|||
queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, buf, header_size, e, strlen(e), false);
|
||||
}
|
||||
|
||||
void
|
||||
drop_send_einval(Window *w) {
|
||||
drop_send_error(w, EINVAL);
|
||||
}
|
||||
|
||||
void
|
||||
drop_request_data(Window *w, const char *mime) {
|
||||
if (w->drop.getting_data_for_mime) { free(w->drop.getting_data_for_mime); w->drop.getting_data_for_mime = NULL; }
|
||||
|
|
@ -370,6 +403,387 @@ drop_dispatch_data(Window *w, const char *mime, const char *data, ssize_t sz) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---- Remote file / directory transfer (t=s, t=d) ----
|
||||
|
||||
static void
|
||||
url_decode_inplace(char *str) {
|
||||
char *src = str, *dst = str;
|
||||
while (*src) {
|
||||
if (*src == '%' && src[1] && src[2]) {
|
||||
unsigned int hi = 0, lo = 0;
|
||||
char c1 = src[1], c2 = src[2];
|
||||
if (c1 >= '0' && c1 <= '9') hi = c1 - '0';
|
||||
else if (c1 >= 'a' && c1 <= 'f') hi = c1 - 'a' + 10;
|
||||
else if (c1 >= 'A' && c1 <= 'F') hi = c1 - 'A' + 10;
|
||||
else { *dst++ = *src++; continue; }
|
||||
if (c2 >= '0' && c2 <= '9') lo = c2 - '0';
|
||||
else if (c2 >= 'a' && c2 <= 'f') lo = c2 - 'a' + 10;
|
||||
else if (c2 >= 'A' && c2 <= 'F') lo = c2 - 'A' + 10;
|
||||
else { *dst++ = *src++; continue; }
|
||||
*dst++ = (char)((hi << 4) | lo);
|
||||
src += 3;
|
||||
} else {
|
||||
*dst++ = *src++;
|
||||
}
|
||||
}
|
||||
*dst = 0;
|
||||
}
|
||||
|
||||
/* Return the nth (0-based) file path from a text/uri-list.
|
||||
* On success returns true and *path_out is a malloc'd absolute resolved path.
|
||||
* On failure returns false and *error_out points to a static error string. */
|
||||
static bool
|
||||
get_nth_file_url(const char *uri_list, size_t uri_list_sz, int n, char **path_out, const char **error_out) {
|
||||
*path_out = NULL;
|
||||
RAII_ALLOC(char, buf, malloc(uri_list_sz + 1));
|
||||
if (!buf) { *error_out = "ENOMEM"; return false; }
|
||||
memcpy(buf, uri_list, uri_list_sz);
|
||||
buf[uri_list_sz] = 0;
|
||||
|
||||
const char *found_line = NULL;
|
||||
char *p = buf;
|
||||
while (*p) {
|
||||
char *eol = p + strcspn(p, "\r\n");
|
||||
char saved = *eol; *eol = 0;
|
||||
/* trim trailing whitespace */
|
||||
char *end = eol;
|
||||
while (end > p && (end[-1] == ' ' || end[-1] == '\t')) { end--; *end = 0; }
|
||||
if (*p && *p != '#') {
|
||||
if (n <= 0) { found_line = p; break; }
|
||||
n--;
|
||||
}
|
||||
if (saved == 0) break;
|
||||
p = eol + 1;
|
||||
while (*p == '\r' || *p == '\n') p++;
|
||||
}
|
||||
|
||||
if (!found_line) { *error_out = "ENOENT"; return false; }
|
||||
|
||||
/* Must be a file:// URL */
|
||||
if (strncmp(found_line, "file://", 7) != 0) { *error_out = "EUNKNOWN"; return false; }
|
||||
|
||||
const char *rest = found_line + 7;
|
||||
const char *slash = strchr(rest, '/');
|
||||
if (!slash) { *error_out = "EINVAL"; return false; }
|
||||
|
||||
/* Host part must be empty or "localhost" */
|
||||
size_t host_len = (size_t)(slash - rest);
|
||||
if (host_len > 0 && !(host_len == 9 && strncasecmp(rest, "localhost", 9) == 0)) {
|
||||
*error_out = "EUNKNOWN"; return false;
|
||||
}
|
||||
|
||||
RAII_ALLOC(char, path, strdup(slash));
|
||||
if (!path) { *error_out = "ENOMEM"; return false; }
|
||||
url_decode_inplace(path);
|
||||
if (path[0] != '/') { *error_out = "EINVAL"; return false; }
|
||||
|
||||
char resolved[PATH_MAX];
|
||||
if (!realpath(path, resolved)) {
|
||||
switch (errno) {
|
||||
case ENOENT: case ENOTDIR: *error_out = "ENOENT"; break;
|
||||
case EACCES: case EPERM: *error_out = "EPERM"; break;
|
||||
case ELOOP: *error_out = "ENOENT"; break;
|
||||
default: *error_out = "EINVAL"; break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
*path_out = strdup(resolved);
|
||||
if (!*path_out) { *error_out = "ENOMEM"; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Send error using a literal string (for cases where we have a string, not int). */
|
||||
static void
|
||||
drop_send_error_str(Window *w, const char *err_name) {
|
||||
char buf[128];
|
||||
int header_size = snprintf(buf, sizeof(buf), "\x1b]%d;t=R", DND_CODE);
|
||||
queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, buf, header_size, err_name, strlen(err_name), false);
|
||||
}
|
||||
|
||||
/* Read a regular file and send its contents as t=r chunks followed by an
|
||||
* empty end-of-data t=r. */
|
||||
static void
|
||||
drop_send_file_data(Window *w, const char *path) {
|
||||
struct stat st;
|
||||
if (stat(path, &st) < 0) {
|
||||
switch (errno) {
|
||||
case ENOENT: case ENOTDIR: drop_send_error(w, ENOENT); break;
|
||||
case EACCES: case EPERM: drop_send_error(w, EPERM); break;
|
||||
default: drop_send_error(w, EIO); break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!S_ISREG(st.st_mode)) { drop_send_error(w, EINVAL); return; }
|
||||
|
||||
int fd = open(path, O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) {
|
||||
switch (errno) {
|
||||
case ENOENT: case ENOTDIR: drop_send_error(w, ENOENT); break;
|
||||
case EACCES: case EPERM: drop_send_error(w, EPERM); break;
|
||||
default: drop_send_error(w, EIO); break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
size_t data_sz = (size_t)st.st_size;
|
||||
char *data = NULL;
|
||||
if (data_sz) {
|
||||
data = malloc(data_sz);
|
||||
if (!data) { close(fd); drop_send_error(w, EIO); return; }
|
||||
size_t done = 0;
|
||||
while (done < data_sz) {
|
||||
ssize_t n = read(fd, data + done, data_sz - done);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
free(data); close(fd); drop_send_error(w, EIO); return;
|
||||
}
|
||||
if (n == 0) break;
|
||||
done += (size_t)n;
|
||||
}
|
||||
data_sz = done;
|
||||
}
|
||||
close(fd);
|
||||
|
||||
char hdr[128];
|
||||
int hdr_sz = snprintf(hdr, sizeof(hdr), "\x1b]%d;t=r", DND_CODE);
|
||||
if (data_sz)
|
||||
queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, hdr, hdr_sz, data, data_sz, true);
|
||||
free(data);
|
||||
/* end-of-data signal */
|
||||
queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, hdr, hdr_sz, NULL, 0, true);
|
||||
}
|
||||
|
||||
/* Allocate a new DirHandle for the given path and entries (takes ownership of
|
||||
* entries array and its strings). Returns the new handle id. */
|
||||
static uint32_t
|
||||
drop_alloc_dir_handle(Window *w, const char *path, char **entries, size_t num_entries) {
|
||||
ensure_space_for(&w->drop, dir_handles, DirHandle, w->drop.num_dir_handles + 1, dir_handles_capacity, 4, true);
|
||||
w->drop.next_dir_handle_id++;
|
||||
if (w->drop.next_dir_handle_id == 0) w->drop.next_dir_handle_id = 1;
|
||||
DirHandle *h = &w->drop.dir_handles[w->drop.num_dir_handles++];
|
||||
zero_at_ptr(h);
|
||||
h->id = w->drop.next_dir_handle_id;
|
||||
h->path = strdup(path);
|
||||
if (!h->path) fatal("Out of memory");
|
||||
h->entries = entries;
|
||||
h->num_entries = num_entries;
|
||||
return h->id;
|
||||
}
|
||||
|
||||
static DirHandle *
|
||||
drop_find_dir_handle(Window *w, uint32_t id) {
|
||||
for (size_t i = 0; i < w->drop.num_dir_handles; i++)
|
||||
if (w->drop.dir_handles[i].id == id) return &w->drop.dir_handles[i];
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Open a directory, build the null-separated listing, create a handle, and
|
||||
* send the listing to the client as a t=d:x=handle_id response. */
|
||||
static void
|
||||
drop_send_dir_listing(Window *w, const char *path) {
|
||||
struct stat st;
|
||||
if (stat(path, &st) < 0) { drop_send_error(w, EIO); return; }
|
||||
|
||||
DIR *dir = opendir(path);
|
||||
if (!dir) {
|
||||
switch (errno) {
|
||||
case ENOENT: case ENOTDIR: drop_send_error(w, ENOENT); break;
|
||||
case EACCES: case EPERM: drop_send_error(w, EPERM); break;
|
||||
default: drop_send_error(w, EIO); break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* Build null-separated payload: unique_id\0entry1\0entry2\0... */
|
||||
size_t payload_cap = 4096, payload_sz = 0;
|
||||
char *payload = malloc(payload_cap);
|
||||
if (!payload) { closedir(dir); drop_send_error(w, EIO); return; }
|
||||
|
||||
/* First entry: unique identifier (device:inode) */
|
||||
char uid[64];
|
||||
int uid_len = snprintf(uid, sizeof(uid), "%llu:%llu",
|
||||
(unsigned long long)st.st_dev,
|
||||
(unsigned long long)st.st_ino);
|
||||
|
||||
#define APPEND(s, n) do { \
|
||||
size_t _n = (size_t)(n); \
|
||||
size_t _need = payload_sz + _n + 1; \
|
||||
if (_need > payload_cap) { \
|
||||
while (payload_cap < _need) payload_cap *= 2; \
|
||||
char *_np = realloc(payload, payload_cap); \
|
||||
if (!_np) { free(payload); closedir(dir); drop_send_error(w, EIO); return; } \
|
||||
payload = _np; \
|
||||
} \
|
||||
memcpy(payload + payload_sz, (s), _n); \
|
||||
payload_sz += _n; \
|
||||
payload[payload_sz++] = 0; \
|
||||
} while(0)
|
||||
|
||||
APPEND(uid, uid_len);
|
||||
|
||||
/* Collect directory entries */
|
||||
size_t ents_cap = 16, ents_num = 0;
|
||||
char **ents = malloc(sizeof(char *) * ents_cap);
|
||||
if (!ents) { free(payload); closedir(dir); drop_send_error(w, EIO); return; }
|
||||
|
||||
struct dirent *de;
|
||||
while ((de = readdir(dir)) != NULL) {
|
||||
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue;
|
||||
|
||||
unsigned char dtype = de->d_type;
|
||||
if (dtype == DT_UNKNOWN) {
|
||||
/* Fall back to lstat when d_type is unavailable */
|
||||
char full[PATH_MAX];
|
||||
if (snprintf(full, sizeof(full), "%s/%s", path, de->d_name) >= (int)sizeof(full)) continue;
|
||||
struct stat est;
|
||||
if (lstat(full, &est) < 0) continue;
|
||||
if (S_ISREG(est.st_mode)) dtype = DT_REG;
|
||||
else if (S_ISDIR(est.st_mode)) dtype = DT_DIR;
|
||||
else if (S_ISLNK(est.st_mode)) dtype = DT_LNK;
|
||||
else continue;
|
||||
}
|
||||
if (dtype != DT_REG && dtype != DT_DIR && dtype != DT_LNK) continue;
|
||||
|
||||
if (ents_num >= ents_cap) {
|
||||
ents_cap *= 2;
|
||||
char **ne = realloc(ents, sizeof(char *) * ents_cap);
|
||||
if (!ne) {
|
||||
for (size_t i = 0; i < ents_num; i++) free(ents[i]);
|
||||
free(ents); free(payload); closedir(dir);
|
||||
drop_send_error(w, EIO); return;
|
||||
}
|
||||
ents = ne;
|
||||
}
|
||||
ents[ents_num] = strdup(de->d_name);
|
||||
if (!ents[ents_num]) {
|
||||
for (size_t i = 0; i < ents_num; i++) free(ents[i]);
|
||||
free(ents); free(payload); closedir(dir);
|
||||
drop_send_error(w, EIO); return;
|
||||
}
|
||||
ents_num++;
|
||||
|
||||
APPEND(de->d_name, strlen(de->d_name));
|
||||
}
|
||||
closedir(dir);
|
||||
|
||||
#undef APPEND
|
||||
|
||||
uint32_t handle_id = drop_alloc_dir_handle(w, path, ents, ents_num);
|
||||
|
||||
char hdr[128];
|
||||
int hdr_sz = snprintf(hdr, sizeof(hdr), "\x1b]%d;t=d:x=%u", DND_CODE, (unsigned)handle_id);
|
||||
/* payload_sz includes a trailing null; omit it – the null-separated format
|
||||
* does not require a trailing null after the last entry. */
|
||||
size_t send_sz = payload_sz > 0 ? payload_sz - 1 : 0;
|
||||
if (send_sz)
|
||||
queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, hdr, hdr_sz, payload, send_sz, true);
|
||||
free(payload);
|
||||
/* end-of-listing signal (empty payload) */
|
||||
queue_payload_to_child(w->id, w->drop.client_id, &w->drop.pending, hdr, hdr_sz, NULL, 0, true);
|
||||
}
|
||||
|
||||
/* Handle a t=s request: send the file/directory at URI-list index idx. */
|
||||
void
|
||||
drop_request_uri_data(Window *w, const char *payload, size_t payload_sz) {
|
||||
if (!w->drop.uri_list || !w->drop.uri_list_sz) {
|
||||
drop_send_error(w, EINVAL); return;
|
||||
}
|
||||
|
||||
/* Payload format: "text/uri-list:idx" */
|
||||
const char *colon = memchr(payload, ':', payload_sz);
|
||||
if (!colon) { drop_send_error(w, EINVAL); return; }
|
||||
|
||||
size_t mime_len = (size_t)(colon - payload);
|
||||
if (mime_len != 13 || strncmp(payload, "text/uri-list", 13) != 0) {
|
||||
drop_send_error(w, EINVAL); return;
|
||||
}
|
||||
|
||||
const char *idx_str = colon + 1;
|
||||
size_t idx_len = payload_sz - mime_len - 1;
|
||||
char idx_buf[32];
|
||||
if (!idx_len || idx_len >= sizeof(idx_buf)) { drop_send_error(w, EINVAL); return; }
|
||||
memcpy(idx_buf, idx_str, idx_len);
|
||||
idx_buf[idx_len] = 0;
|
||||
|
||||
char *endp;
|
||||
long idx = strtol(idx_buf, &endp, 10);
|
||||
if (endp == idx_buf || *endp != 0 || idx < 0) { drop_send_error(w, EINVAL); return; }
|
||||
|
||||
char *path = NULL;
|
||||
const char *err = NULL;
|
||||
if (!get_nth_file_url(w->drop.uri_list, w->drop.uri_list_sz, (int)idx, &path, &err)) {
|
||||
drop_send_error_str(w, err);
|
||||
return;
|
||||
}
|
||||
|
||||
struct stat st;
|
||||
if (stat(path, &st) < 0) {
|
||||
free(path);
|
||||
switch (errno) {
|
||||
case ENOENT: case ENOTDIR: drop_send_error(w, ENOENT); break;
|
||||
case EACCES: case EPERM: drop_send_error(w, EPERM); break;
|
||||
default: drop_send_error(w, EIO); break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
drop_send_dir_listing(w, path);
|
||||
} else if (S_ISREG(st.st_mode)) {
|
||||
drop_send_file_data(w, path);
|
||||
} else {
|
||||
drop_send_error(w, EINVAL);
|
||||
}
|
||||
free(path);
|
||||
}
|
||||
|
||||
/* Handle a t=d request from the client.
|
||||
* handle_id: the directory handle (x= key).
|
||||
* entry_num: 0 means close the handle; >=1 means read that entry (1-based). */
|
||||
void
|
||||
drop_handle_dir_request(Window *w, uint32_t handle_id, int32_t entry_num) {
|
||||
if (!handle_id) { drop_send_error(w, EINVAL); return; }
|
||||
|
||||
DirHandle *h = drop_find_dir_handle(w, handle_id);
|
||||
if (!h) { drop_send_error(w, EINVAL); return; }
|
||||
|
||||
if (entry_num == 0) {
|
||||
/* Close the handle */
|
||||
size_t hidx = (size_t)(h - w->drop.dir_handles);
|
||||
drop_free_dir_handle(h);
|
||||
remove_i_from_array(w->drop.dir_handles, hidx, w->drop.num_dir_handles);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Read the entry at 1-based index */
|
||||
size_t eidx = (size_t)(entry_num - 1);
|
||||
if (eidx >= h->num_entries) { drop_send_error(w, ENOENT); return; }
|
||||
|
||||
char full[PATH_MAX];
|
||||
if (snprintf(full, sizeof(full), "%s/%s", h->path, h->entries[eidx]) >= (int)sizeof(full)) {
|
||||
drop_send_error(w, EIO); return;
|
||||
}
|
||||
|
||||
struct stat st;
|
||||
if (stat(full, &st) < 0) {
|
||||
switch (errno) {
|
||||
case ENOENT: case ENOTDIR: case ELOOP: drop_send_error(w, ENOENT); break;
|
||||
case EACCES: case EPERM: drop_send_error(w, EPERM); break;
|
||||
default: drop_send_error(w, EIO); break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
drop_send_dir_listing(w, full);
|
||||
} else if (S_ISREG(st.st_mode)) {
|
||||
drop_send_file_data(w, full);
|
||||
} else {
|
||||
drop_send_error(w, EINVAL);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
drop_left_child(Window *w) {
|
||||
w->drop.hovered = false;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ 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_request_data(Window *w, const char *mime);
|
||||
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_set_status(Window *w, int operation, const char *payload, size_t payload_sz, bool more);
|
||||
size_t drop_update_mimes(Window *w, const char **allowed_mimes, size_t allowed_mimes_count);
|
||||
void drop_dispatch_data(Window *w, const char *mime_type, const char *data, ssize_t sz);
|
||||
|
|
|
|||
|
|
@ -1529,6 +1529,18 @@ screen_handle_dnd_command(Screen *self, const DnDCommand *cmd, const uint8_t *pa
|
|||
drop_request_data(w, buf);
|
||||
}
|
||||
} break;
|
||||
case 's': {
|
||||
if (cmd->payload_sz)
|
||||
drop_request_uri_data(w, (const char*)payload, cmd->payload_sz);
|
||||
else
|
||||
drop_send_einval(w);
|
||||
} break;
|
||||
case 'd': {
|
||||
if (cmd->cell_x > 0)
|
||||
drop_handle_dir_request(w, (uint32_t)cmd->cell_x, cmd->cell_y);
|
||||
else
|
||||
drop_send_einval(w);
|
||||
} break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -225,6 +225,13 @@ typedef struct PendingData {
|
|||
PendingEntry *items; size_t count, capacity;
|
||||
} PendingData;
|
||||
|
||||
typedef struct DirHandle {
|
||||
char *path; /* absolute path of the directory (malloc'd) */
|
||||
char **entries; /* array of entry names (each malloc'd) */
|
||||
size_t num_entries;
|
||||
uint32_t id; /* handle id, 1-based; 0 = invalid */
|
||||
} DirHandle;
|
||||
|
||||
typedef struct Window {
|
||||
id_type id;
|
||||
bool visible;
|
||||
|
|
@ -269,6 +276,9 @@ typedef struct Window {
|
|||
char *accepted_mimes; size_t accepted_mimes_sz;
|
||||
int accepted_operation; bool accept_in_progress;
|
||||
char *getting_data_for_mime;
|
||||
|
||||
DirHandle *dir_handles; size_t num_dir_handles, dir_handles_capacity;
|
||||
uint32_t next_dir_handle_id;
|
||||
} drop;
|
||||
} Window;
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,28 @@ def client_request_data(mime: str = '', client_id: int = 0) -> bytes:
|
|||
return _osc(f'{meta};{mime}')
|
||||
|
||||
|
||||
def client_request_uri_data(idx: int, client_id: int = 0) -> bytes:
|
||||
"""Escape code a client sends to request a file from the URI list (t=s ; text/uri-list:idx)."""
|
||||
meta = f'{DND_CODE};t=s'
|
||||
if client_id:
|
||||
meta += f':i={client_id}'
|
||||
return _osc(f'{meta};text/uri-list:{idx}')
|
||||
|
||||
|
||||
def client_dir_read(handle_id: int, entry_num: int | None = None, client_id: int = 0) -> bytes:
|
||||
"""Escape code for a directory request (t=d:x=handle_id[:y=entry_num]).
|
||||
|
||||
* entry_num=None → close the directory handle.
|
||||
* entry_num>=1 → read that entry (1-based).
|
||||
"""
|
||||
meta = f'{DND_CODE};t=d:x={handle_id}'
|
||||
if entry_num is not None:
|
||||
meta += f':y={entry_num}'
|
||||
if client_id:
|
||||
meta += f':i={client_id}'
|
||||
return _osc(meta)
|
||||
|
||||
|
||||
# ---- escape-code decoder used by assertions ---------------------------------
|
||||
|
||||
_OSC_RE = re.compile(
|
||||
|
|
@ -474,3 +496,341 @@ class TestDnDProtocol(BaseTest):
|
|||
# Only the end signal should be present.
|
||||
self.assertEqual(len(r_events), 1, raw)
|
||||
self.ae(r_events[0]['payload'], b'')
|
||||
|
||||
# ---- t=s / t=d (remote file/directory transfer) tests ----------------
|
||||
|
||||
def _setup_uri_drop(self, screen, wid, cap, uri_list_data: bytes, mimes=None):
|
||||
"""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'))
|
||||
dnd_test_set_mouse_pos(wid, 0, 0, 0, 0)
|
||||
dnd_test_fake_drop_event(wid, True, mimes)
|
||||
cap.consume(wid)
|
||||
# Client requests and receives the URI list
|
||||
parse_bytes(screen, client_request_data('text/uri-list'))
|
||||
dnd_test_fake_drop_data(wid, 'text/uri-list', uri_list_data)
|
||||
cap.consume(wid) # discard t=r data for text/uri-list
|
||||
|
||||
def test_uri_file_transfer_basic(self) -> None:
|
||||
"""t=s request sends the content of a regular file as t=r chunks."""
|
||||
import os, tempfile
|
||||
content = b'Hello, remote DnD world!\n' * 100
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
f.write(content)
|
||||
fpath = f.name
|
||||
try:
|
||||
uri_list = f'file://{fpath}\r\n'.encode()
|
||||
with dnd_test_window() as (osw, wid, screen, cap):
|
||||
self._setup_uri_drop(screen, wid, cap, uri_list)
|
||||
parse_bytes(screen, client_request_uri_data(0))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
r_events = [e for e in events if e['type'] == 'r']
|
||||
self.assertTrue(r_events, 'no t=r events')
|
||||
combined = b''.join(e['payload'] for e in r_events)
|
||||
self.ae(combined, content)
|
||||
# Last chunk must be the empty end-of-data signal
|
||||
self.ae(r_events[-1]['payload'], b'')
|
||||
finally:
|
||||
os.unlink(fpath)
|
||||
|
||||
def test_uri_file_transfer_integrity(self) -> None:
|
||||
"""File content is transferred byte-for-byte (binary integrity)."""
|
||||
import os, tempfile
|
||||
# Use binary content with all byte values to check integrity
|
||||
content = bytes(range(256)) * 512 # 128 KiB
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
f.write(content)
|
||||
fpath = f.name
|
||||
try:
|
||||
uri_list = f'file://{fpath}\r\n'.encode()
|
||||
with dnd_test_window() as (osw, wid, screen, cap):
|
||||
self._setup_uri_drop(screen, wid, cap, uri_list)
|
||||
parse_bytes(screen, client_request_uri_data(0))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
r_events = [e for e in events if e['type'] == 'r']
|
||||
# Exclude the empty end-of-data entry when concatenating
|
||||
combined = b''.join(e['payload'] for e in r_events if e['payload'])
|
||||
self.ae(combined, content)
|
||||
finally:
|
||||
os.unlink(fpath)
|
||||
|
||||
def test_uri_file_transfer_enoent(self) -> None:
|
||||
"""t=s with an out-of-range index returns ENOENT."""
|
||||
uri_list = b'file:///tmp/no_such_file_exists_dnd_test_xyz\r\n'
|
||||
with dnd_test_window() as (osw, wid, screen, cap):
|
||||
self._setup_uri_drop(screen, wid, cap, uri_list)
|
||||
# Index 0 refers to a non-existent file
|
||||
parse_bytes(screen, client_request_uri_data(0))
|
||||
events = self._get_events(cap, wid)
|
||||
self.assertEqual(len(events), 1, events)
|
||||
self.ae(events[0]['type'], 'R')
|
||||
self.assertIn(events[0]['payload'].strip(), [b'ENOENT', b'EPERM'])
|
||||
|
||||
def test_uri_file_transfer_out_of_bounds(self) -> None:
|
||||
"""t=s with an index beyond the URI list returns ENOENT."""
|
||||
import os, tempfile
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
fpath = f.name
|
||||
try:
|
||||
uri_list = f'file://{fpath}\r\n'.encode()
|
||||
with dnd_test_window() as (osw, wid, screen, cap):
|
||||
self._setup_uri_drop(screen, wid, cap, uri_list)
|
||||
parse_bytes(screen, client_request_uri_data(99)) # out of range
|
||||
events = self._get_events(cap, wid)
|
||||
self.assertEqual(len(events), 1, events)
|
||||
self.ae(events[0]['type'], 'R')
|
||||
self.ae(events[0]['payload'].strip(), b'ENOENT')
|
||||
finally:
|
||||
os.unlink(fpath)
|
||||
|
||||
def test_uri_request_without_uri_list_returns_einval(self) -> None:
|
||||
"""t=s without prior text/uri-list request returns EINVAL."""
|
||||
import os, tempfile
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
fpath = f.name
|
||||
try:
|
||||
with dnd_test_window() as (osw, wid, screen, cap):
|
||||
parse_bytes(screen, client_register('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)
|
||||
# Do NOT request text/uri-list first
|
||||
parse_bytes(screen, client_request_uri_data(0))
|
||||
events = self._get_events(cap, wid)
|
||||
self.assertEqual(len(events), 1, events)
|
||||
self.ae(events[0]['type'], 'R')
|
||||
self.ae(events[0]['payload'].strip(), b'EINVAL')
|
||||
finally:
|
||||
os.unlink(fpath)
|
||||
|
||||
def test_uri_non_regular_file_returns_einval(self) -> None:
|
||||
"""t=s for a non-regular file (e.g. /dev/null) returns EINVAL."""
|
||||
uri_list = b'file:///dev/null\r\n'
|
||||
with dnd_test_window() as (osw, wid, screen, cap):
|
||||
self._setup_uri_drop(screen, wid, cap, uri_list)
|
||||
parse_bytes(screen, client_request_uri_data(0))
|
||||
events = self._get_events(cap, wid)
|
||||
self.assertEqual(len(events), 1, events)
|
||||
self.ae(events[0]['type'], 'R')
|
||||
self.ae(events[0]['payload'].strip(), b'EINVAL')
|
||||
|
||||
def test_uri_directory_transfer_tree(self) -> None:
|
||||
"""Full directory tree transfer: listing, sub-dirs, file integrity."""
|
||||
import os, tempfile, hashlib
|
||||
|
||||
# Build a tree: root/ a.txt b/ b/c.txt b/d/ b/d/e.txt
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
a_content = b'file a content\n' * 50
|
||||
bc_content = bytes(range(256)) * 20 # binary data
|
||||
bde_content = b'deep nested file\n'
|
||||
|
||||
(open(os.path.join(root, 'a.txt'), 'wb')).write(a_content)
|
||||
os.makedirs(os.path.join(root, 'b', 'd'))
|
||||
(open(os.path.join(root, 'b', 'c.txt'), 'wb')).write(bc_content)
|
||||
(open(os.path.join(root, 'b', 'd', 'e.txt'), 'wb')).write(bde_content)
|
||||
|
||||
uri_list = f'file://{root}\r\n'.encode()
|
||||
with dnd_test_window() as (osw, wid, screen, cap):
|
||||
self._setup_uri_drop(screen, wid, cap, uri_list)
|
||||
|
||||
# Request the root directory (idx=0)
|
||||
parse_bytes(screen, client_request_uri_data(0))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
d_events = [e for e in events if e['type'] == 'd']
|
||||
self.assertTrue(d_events, 'expected t=d listing for root')
|
||||
|
||||
root_listing_payload = b''.join(
|
||||
chunk for e in d_events for chunk in e['chunks'] if chunk
|
||||
)
|
||||
root_handle_id = int(d_events[0]['meta']['x'])
|
||||
self.assertGreater(root_handle_id, 0)
|
||||
|
||||
# Decode null-separated entries
|
||||
root_entries = [e for e in root_listing_payload.split(b'\x00') if e]
|
||||
# First entry is the unique identifier; remainder are file/dir names
|
||||
self.assertGreater(len(root_entries), 1,
|
||||
f'expected entries, got {root_entries}')
|
||||
entry_names = {e.decode() for e in root_entries[1:]}
|
||||
self.assertIn('a.txt', entry_names)
|
||||
self.assertIn('b', entry_names)
|
||||
|
||||
# Find index of 'a.txt' in the entries list (1-based for t=d:y=)
|
||||
entries_list = [e.decode() for e in root_entries[1:]]
|
||||
a_idx = entries_list.index('a.txt') + 1
|
||||
b_idx = entries_list.index('b') + 1
|
||||
|
||||
# Read a.txt
|
||||
parse_bytes(screen, client_dir_read(root_handle_id, a_idx))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
r_events = [e for e in events if e['type'] == 'r']
|
||||
a_data = b''.join(e['payload'] for e in r_events if e['payload'])
|
||||
self.ae(a_data, a_content)
|
||||
|
||||
# Read sub-directory b → should get a new t=d listing
|
||||
parse_bytes(screen, client_dir_read(root_handle_id, b_idx))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
b_d_events = [e for e in events if e['type'] == 'd']
|
||||
self.assertTrue(b_d_events, 'expected t=d listing for b/')
|
||||
|
||||
b_listing_payload = b''.join(
|
||||
chunk for e in b_d_events for chunk in e['chunks'] if chunk
|
||||
)
|
||||
b_handle_id = int(b_d_events[0]['meta']['x'])
|
||||
self.assertNotEqual(b_handle_id, root_handle_id)
|
||||
|
||||
b_entries = [e for e in b_listing_payload.split(b'\x00') if e]
|
||||
b_names = {e.decode() for e in b_entries[1:]}
|
||||
self.assertIn('c.txt', b_names)
|
||||
self.assertIn('d', b_names)
|
||||
|
||||
b_entries_list = [e.decode() for e in b_entries[1:]]
|
||||
bc_idx = b_entries_list.index('c.txt') + 1
|
||||
bd_idx = b_entries_list.index('d') + 1
|
||||
|
||||
# Read b/c.txt (binary integrity)
|
||||
parse_bytes(screen, client_dir_read(b_handle_id, bc_idx))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
r_events = [e for e in events if e['type'] == 'r']
|
||||
bc_data = b''.join(e['payload'] for e in r_events if e['payload'])
|
||||
self.ae(bc_data, bc_content)
|
||||
# Check SHA-256 integrity
|
||||
self.ae(hashlib.sha256(bc_data).digest(),
|
||||
hashlib.sha256(bc_content).digest())
|
||||
|
||||
# Read sub-directory b/d → yet another t=d listing
|
||||
parse_bytes(screen, client_dir_read(b_handle_id, bd_idx))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
bd_d_events = [e for e in events if e['type'] == 'd']
|
||||
self.assertTrue(bd_d_events, 'expected t=d listing for b/d/')
|
||||
|
||||
bd_listing_payload = b''.join(
|
||||
chunk for e in bd_d_events for chunk in e['chunks'] if chunk
|
||||
)
|
||||
bd_handle_id = int(bd_d_events[0]['meta']['x'])
|
||||
bd_entries = [e for e in bd_listing_payload.split(b'\x00') if e]
|
||||
bd_names = {e.decode() for e in bd_entries[1:]}
|
||||
self.assertIn('e.txt', bd_names)
|
||||
|
||||
bd_entries_list = [e.decode() for e in bd_entries[1:]]
|
||||
bde_idx = bd_entries_list.index('e.txt') + 1
|
||||
|
||||
# Read b/d/e.txt
|
||||
parse_bytes(screen, client_dir_read(bd_handle_id, bde_idx))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
r_events = [e for e in events if e['type'] == 'r']
|
||||
bde_data = b''.join(e['payload'] for e in r_events if e['payload'])
|
||||
self.ae(bde_data, bde_content)
|
||||
|
||||
# Close all directory handles
|
||||
parse_bytes(screen, client_dir_read(bd_handle_id))
|
||||
parse_bytes(screen, client_dir_read(b_handle_id))
|
||||
parse_bytes(screen, client_dir_read(root_handle_id))
|
||||
# No error output expected from close operations
|
||||
self._assert_no_output(cap, wid)
|
||||
|
||||
def test_dir_handle_close_and_reuse(self) -> None:
|
||||
"""Closing a directory handle invalidates it; subsequent requests return EINVAL."""
|
||||
import os, tempfile
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
open(os.path.join(root, 'f.txt'), 'w').close()
|
||||
uri_list = f'file://{root}\r\n'.encode()
|
||||
with dnd_test_window() as (osw, wid, screen, cap):
|
||||
self._setup_uri_drop(screen, wid, cap, uri_list)
|
||||
parse_bytes(screen, client_request_uri_data(0))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
d_ev = [e for e in events if e['type'] == 'd']
|
||||
self.assertTrue(d_ev)
|
||||
hid = int(d_ev[0]['meta']['x'])
|
||||
|
||||
# Close the handle
|
||||
parse_bytes(screen, client_dir_read(hid))
|
||||
self._assert_no_output(cap, wid)
|
||||
|
||||
# Now try to read from the closed handle → EINVAL
|
||||
parse_bytes(screen, client_dir_read(hid, 1))
|
||||
events = self._get_events(cap, wid)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.ae(events[0]['type'], 'R')
|
||||
self.ae(events[0]['payload'].strip(), b'EINVAL')
|
||||
|
||||
def test_dir_entry_out_of_bounds_returns_enoent(self) -> None:
|
||||
"""Reading a directory entry with an out-of-range index returns ENOENT."""
|
||||
import os, tempfile
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
open(os.path.join(root, 'only.txt'), 'w').close()
|
||||
uri_list = f'file://{root}\r\n'.encode()
|
||||
with dnd_test_window() as (osw, wid, screen, cap):
|
||||
self._setup_uri_drop(screen, wid, cap, uri_list)
|
||||
parse_bytes(screen, client_request_uri_data(0))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
d_ev = [e for e in events if e['type'] == 'd']
|
||||
hid = int(d_ev[0]['meta']['x'])
|
||||
|
||||
# Entry 999 does not exist
|
||||
parse_bytes(screen, client_dir_read(hid, 999))
|
||||
events = self._get_events(cap, wid)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.ae(events[0]['type'], 'R')
|
||||
self.ae(events[0]['payload'].strip(), b'ENOENT')
|
||||
|
||||
def test_dir_unique_identifier_prevents_loops(self) -> None:
|
||||
"""Each directory listing starts with a unique id (dev:inode format)."""
|
||||
import os, tempfile
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
sub = os.path.join(root, 'sub')
|
||||
os.mkdir(sub)
|
||||
uri_list = f'file://{root}\r\n'.encode()
|
||||
with dnd_test_window() as (osw, wid, screen, cap):
|
||||
self._setup_uri_drop(screen, wid, cap, uri_list)
|
||||
parse_bytes(screen, client_request_uri_data(0))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
d_ev = [e for e in events if e['type'] == 'd']
|
||||
root_payload = b''.join(
|
||||
chunk for e in d_ev for chunk in e['chunks'] if chunk
|
||||
)
|
||||
root_handle_id = int(d_ev[0]['meta']['x'])
|
||||
root_uid = root_payload.split(b'\x00')[0].decode()
|
||||
# uid must be non-empty and contain a colon (dev:inode)
|
||||
self.assertIn(':', root_uid, f'uid={root_uid!r}')
|
||||
|
||||
# Get the sub directory listing to compare identifiers
|
||||
entries = [e.decode() for e in root_payload.split(b'\x00')[1:] if e]
|
||||
sub_idx = entries.index('sub') + 1
|
||||
parse_bytes(screen, client_dir_read(root_handle_id, sub_idx))
|
||||
raw = cap.consume(wid)
|
||||
events = parse_escape_codes_b64(raw)
|
||||
d_ev2 = [e for e in events if e['type'] == 'd']
|
||||
sub_payload = b''.join(
|
||||
chunk for e in d_ev2 for chunk in e['chunks'] if chunk
|
||||
)
|
||||
sub_uid = sub_payload.split(b'\x00')[0].decode() if sub_payload else ''
|
||||
self.assertIn(':', sub_uid, f'sub uid={sub_uid!r}')
|
||||
# Root and sub must have different identifiers
|
||||
self.assertNotEqual(root_uid, sub_uid)
|
||||
|
||||
def test_window_close_during_transfer_no_leak(self) -> None:
|
||||
"""Closing the window while dir handles are open frees all resources (no crash)."""
|
||||
import os, tempfile
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
open(os.path.join(root, 'f.txt'), 'w').close()
|
||||
uri_list = f'file://{root}\r\n'.encode()
|
||||
# The context manager calls dnd_test_cleanup_fake_window on exit,
|
||||
# which calls drop_free_data → drop_free_dir_handles.
|
||||
with dnd_test_window() as (osw, wid, screen, cap):
|
||||
self._setup_uri_drop(screen, wid, cap, uri_list)
|
||||
parse_bytes(screen, client_request_uri_data(0))
|
||||
cap.consume(wid)
|
||||
# Intentionally leave the handle open – cleanup happens in __exit__
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue