From b869fe4e7fc552ff5650f95dbc5d5e494bf56414 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 1 Sep 2026 23:41:07 +0530 Subject: [PATCH] macOS: Fix pasting paths from dropped file promises Previously they were always invalid (deleted in a few milliseconds). Now, kitty preserves them for ten minutes or till kitty exit, whichever is sooner. This is still not ideal, but is likely the best that can be done with File Promises since we have no idea when the client will consume them. It is at any rate better than the old behavior and is superior to the alternative of letting the drag source keep the promised data alive for some indeterminate time period. Now we can guarantee ten minutes of validity. Note I still need to test this on an actual mac as I currently dont have access to one. Fixes #10430 --- docs/changelog.rst | 4 + glfw/cocoa_platform.h | 1 + glfw/cocoa_window.m | 196 ++++++++++++++++++++++++++++++++---------- glfw/glfw.py | 1 + kitty/boss.py | 18 ++++ kitty/glfw-wrapper.c | 3 + kitty/glfw-wrapper.h | 4 + kitty/glfw.c | 9 ++ 8 files changed, 190 insertions(+), 46 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9c0f598c0..b2a80a0f6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -302,6 +302,10 @@ Detailed list of changes field name>`` instead of echoing the field name back verbatim, which allowed using the escape code to make the terminal emit arbitrary printable ASCII text +- macOS: Fix dropping files that are provided as file promises pasting paths to + files that no longer exist. The dropped files are now kept alive for + ten minutes and removed when kitty quits (:iss:`10430`) + 0.48.2 [2026-07-30] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/glfw/cocoa_platform.h b/glfw/cocoa_platform.h index fce008084..79ffae061 100644 --- a/glfw/cocoa_platform.h +++ b/glfw/cocoa_platform.h @@ -125,6 +125,7 @@ typedef struct _GLFWDropData { id path_map; // map MIME to NSError or NSInputStream or NSUrl id pending_requests; bool promises_loaded; + bool keep_temp_dir; // when true temp_dir is not deleted at end of drop, the application owns it } in_progress_drop; } _GLFWDropData; diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index f0bb68ef8..22011e306 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -1449,8 +1449,13 @@ static void free_in_progress_drop_data(_GLFWwindow *window) { _GLFWDropData *d = &window->ns.drop_data; if (d->in_progress_drop.temp_dir) { - NSError *error = nil; - [[NSFileManager defaultManager] removeItemAtURL:d->in_progress_drop.temp_dir error:&error]; + // When the paths of the files fulfilled from promises have been handed + // to the application, it is responsible for removing the temp dir + // holding them, see glfwCocoaPreserveDroppedFilePromises(). + if (!d->in_progress_drop.keep_temp_dir) { + NSError *error = nil; + [[NSFileManager defaultManager] removeItemAtURL:d->in_progress_drop.temp_dir error:&error]; + } [d->in_progress_drop.temp_dir release]; } if (d->in_progress_drop.data_map) [d->in_progress_drop.data_map release]; @@ -1508,6 +1513,30 @@ reset_drop_copy_mimes(_GLFWDropData *d) { return true; } +// Is this promise a promise of a file, whose path is what the drop target +// wants, or merely a promise of the data for some MIME type? +// +// Our own drag source distinguishes between the two by the type of the promise: +// public.file-url for a file and the UTI of the MIME type for data, see +// add_drag_items(). Other applications have no such convention, they promise +// the content type of the promised file, for instance the macOS screenshot +// thumbnail promises public.png, so for them every promise is of a file. +// +// from_self is true only for a drag started by this process, which is the same +// scope in which the rest of the code recognises its own drags, since the MIME +// types used for tab and window drags have the pid embedded in them. A drag +// from a different kitty process is therefore treated like any other +// application's drag. +static bool +file_promise_is_for_a_file(NSFilePromiseReceiver *receiver, bool from_self) { + if (!from_self) return [receiver.fileTypes count] > 0; + for (NSString *uti in receiver.fileTypes) { + UTType *promisedType = [UTType typeWithIdentifier:uti]; + if (promisedType && [promisedType conformsToType:UTTypeFileURL]) return true; + } + return false; +} + - (NSDragOperation)draggingEntered:(id)sender { const NSRect contentRect = [window->ns.view frame]; const NSPoint pos = [sender draggingLocation]; @@ -1517,6 +1546,7 @@ reset_drop_copy_mimes(_GLFWDropData *d) { // Get MIME types from the dragging pasteboard NSPasteboard *pasteboard = [sender draggingPasteboard]; + const bool from_self = ([sender draggingSource] != nil); // Count total types across all pasteboard items plus 2 for uri-list and text/plain size_t max_types = 2; @@ -1552,18 +1582,21 @@ reset_drop_copy_mimes(_GLFWDropData *d) { if (!duplicate) mime_array[mime_count++] = _glfw_strdup(mime); \ } \ } - // Get file promise based types + // Get file promise based types. A promise of a file contributes its path to + // the uri-list, see file_promise_is_for_a_file(). The type of the promised + // data is advertised as well, for clients that want the contents rather + // than the path. for (NSFilePromiseReceiver *receiver in receivers) { + if (!has_uri_list && file_promise_is_for_a_file(receiver, from_self)) { + mime_array[mime_count++] = _glfw_strdup("text/uri-list"); + has_uri_list = true; + } for (NSString *uti in receiver.fileTypes) { UTType *promisedType = [UTType typeWithIdentifier:uti]; - if (promisedType && [promisedType conformsToType:UTTypeFileURL]) { - if (!has_uri_list) { - mime_array[mime_count++] = _glfw_strdup("text/uri-list"); - has_uri_list = true; - } - } else { - add_mime(uti); - } + // A promise of a bare file URL says nothing about the contents of + // the file, so there is no MIME type to advertise for it. + if (promisedType && [promisedType conformsToType:UTTypeFileURL]) continue; + add_mime(uti); } } @@ -1574,7 +1607,6 @@ reset_drop_copy_mimes(_GLFWDropData *d) { window->ns.drop_data.mimes = mime_array; window->ns.drop_data.mimes_count = mime_count; - bool from_self = ([sender draggingSource] != nil); update_drop_source_actions(window, sender); _GLFWDropData *d = &window->ns.drop_data; if (reset_drop_copy_mimes(d)) { @@ -1642,6 +1674,7 @@ create_uri_list(_GLFWDropData *d, NSArray *urls) { [items addObject:url.absoluteString]; } NSString *result = [items componentsJoinedByString:@"\r\n"]; + debug("Drop uri-list: %s\n", [result UTF8String]); NSData *data = [result dataUsingEncoding:NSUTF8StringEncoding]; NSInputStream *inputStream = [NSInputStream inputStreamWithData:data]; [inputStream open]; @@ -1650,11 +1683,14 @@ create_uri_list(_GLFWDropData *d, NSArray *urls) { static void build_uri_list(_GLFWDropData *d) { - if (!d->in_progress_drop.path_map) return; + NSDictionary *path_map = d->in_progress_drop.path_map; + if (!path_map) return; NSMutableArray *urls = [NSMutableArray array]; - for (NSString *mime in d->in_progress_drop.path_map) { - if (![mime hasPrefix:@"kitty-internal/uri-list-item-"]) continue; - id x = d->in_progress_drop.path_map[mime]; + // The keys are numbered consecutively from zero so that the uri-list is in + // the same order as the dragged items, unlike dictionary iteration order. + for (unsigned i = 0;; i++) { + id x = path_map[[NSString stringWithFormat:@"kitty-internal/uri-list-item-%u", i]]; + if (x == nil) break; if ([x isKindOfClass:[NSError class]]) { d->in_progress_drop.data_map[@"text/uri-list"] = x; return; @@ -1674,21 +1710,54 @@ build_uri_list(_GLFWDropData *d) { bool from_self = ([sender draggingSource] != nil); _GLFWDropData *d = &window->ns.drop_data; if (!reset_drop_copy_mimes(d)) return NO; - NSError *mkdirError = nil; - NSURL *tempDirURL = [[NSFileManager defaultManager] URLForDirectory:NSItemReplacementDirectory - inDomain:NSUserDomainMask - appropriateForURL:[NSURL fileURLWithPath:NSTemporaryDirectory()] - create:YES - error:&mkdirError]; - if (!tempDirURL) { - NSLog(@"Failed to create temp dir for file promises: %@", mkdirError); - return NO; + NSPasteboard *pasteboard = [sender draggingPasteboard]; + NSArray *receivers = [pasteboard readObjectsForClasses:@[ [NSFilePromiseReceiver class] ] options:nil]; + if (_glfw.hints.init.debugRendering) { + NSMutableString *types = [NSMutableString stringWithString:@"Drop pasteboard types:"]; + for (NSPasteboardType type in [pasteboard types]) [types appendFormat:@" %@", type]; + for (NSFilePromiseReceiver *receiver in receivers) + [types appendFormat:@"\n\tfile promise for types: %@", [receiver.fileTypes componentsJoinedByString:@" "]]; + debug("%s\n", [types UTF8String]); + } + // When files are promised, any file URLs on the pasteboard point to the + // sender's staging copies, which fulfilling the promises invalidates, as + // fulfillment moves rather than copies them. So the uri-list has to be + // built from the fulfilled paths instead, see build_uri_list(). For + // instance, the macOS screenshot thumbnail stages its file in + // TemporaryItems/NSIRD_screencaptureui_XXXXXX and deletes it once the drag + // is over. + bool has_file_promises = false; + for (NSFilePromiseReceiver *receiver in receivers) { + if (file_promise_is_for_a_file(receiver, from_self)) { + has_file_promises = true; + break; + } + } + NSURL *tempDirURL = nil; + if ([receivers count] > 0) { + // Note that NSItemReplacementDirectory is deliberately not used, even + // though that is the API meant for receiving file promises. It creates + // the directory inside TemporaryItems, which we are not allowed to + // open, so deleting the directory afterwards fails, and unlike an item + // being replaced, these files have to outlive the drop, see + // glfwCocoaPreserveDroppedFilePromises(). + NSString *name = [NSString stringWithFormat:@"kitty-dropped-files-%@", [[NSProcessInfo processInfo] globallyUniqueString]]; + tempDirURL = [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:name] isDirectory:YES]; + NSError *mkdirError = nil; + if (![[NSFileManager defaultManager] createDirectoryAtURL:tempDirURL + withIntermediateDirectories:YES + attributes:@{NSFilePosixPermissions : @(0700)} + error:&mkdirError]) { + NSLog(@"Failed to create temp dir for file promises: %@", mkdirError); + return NO; + } } d->in_progress_drop.data_map = [[NSMutableDictionary alloc] init]; - NSPasteboard *pasteboard = [sender draggingPasteboard]; - NSMutableArray *urls = [NSMutableArray array]; - for (NSURL *url in [pasteboard readObjectsForClasses:@[ [NSURL class] ] options:nil]) [urls addObject:url]; - if ([urls count] > 0) create_uri_list(d, urls); + if (!has_file_promises) { + NSMutableArray *urls = [NSMutableArray array]; + for (NSURL *url in [pasteboard readObjectsForClasses:@[ [NSURL class] ] options:nil]) [urls addObject:url]; + if ([urls count] > 0) create_uri_list(d, urls); + } NSMutableArray *texts = [NSMutableArray array]; for (NSString *text in [pasteboard readObjectsForClasses:@[ [NSString class] ] options:nil]) [texts addObject:text]; if ([texts count] > 0) { @@ -1707,34 +1776,33 @@ build_uri_list(_GLFWDropData *d) { d->in_progress_drop.data_map[@(mime)] = s; } } - NSArray *receivers = [pasteboard readObjectsForClasses:@[ [NSFilePromiseReceiver class] ] options:nil]; if ([receivers count] > 0) { d->in_progress_drop.request_id = ++window->ns.drop_request_counter; unsigned long long request_id = d->in_progress_drop.request_id; GLFWid wid = window->id; NSOperationQueue *workQueue = [[[NSOperationQueue alloc] init] autorelease]; + // Serialize the readers, they all mutate results + workQueue.maxConcurrentOperationCount = 1; dispatch_group_t promiseGroup = dispatch_group_create(); NSMutableDictionary *results = [[NSMutableDictionary alloc] init]; unsigned url_counter = 0; for (NSFilePromiseReceiver *receiver in receivers) { dispatch_group_enter(promiseGroup); NSArray *types = receiver.fileTypes; - unsigned url_list_idx = 0; - for (NSString *type in types) { - UTType *promisedType = [UTType typeWithIdentifier:type]; - if (promisedType && [promisedType conformsToType:UTTypeFileURL]) { - url_list_idx = ++url_counter; - break; - } - } + const bool is_for_a_file = file_promise_is_for_a_file(receiver, from_self); + const unsigned url_list_idx = is_for_a_file ? url_counter++ : 0; [receiver receivePromisedFilesAtDestination:tempDirURL options:@{} operationQueue:workQueue reader:^(NSURL *_Nonnull fileURL, NSError *_Nullable error) { - if (url_list_idx) - results[[NSString stringWithFormat:@"kitty-internal/uri-list-item-%u", url_list_idx - 1]] = - error ? error : fileURL; - else { + // A promise of a file contributes its path to the uri-list + if (is_for_a_file) + results[[NSString stringWithFormat:@"kitty-internal/uri-list-item-%u", url_list_idx]] = + error ? (id)error : (id)fileURL; + // and the contents are made available under the MIME types of the promised data + for (NSString *type in types) { + const char *mime = uti_to_mime(type); + if (!mime || !mime[0] || results[@(mime)] != nil) continue; id result = error; if (!result) { NSInputStream *s = [NSInputStream inputStreamWithURL:fileURL]; @@ -1742,15 +1810,17 @@ build_uri_list(_GLFWDropData *d) { if ([s streamStatus] == NSStreamStatusError) result = [s streamError]; else result = s; } - for (NSString *type in types) { - const char *mime = uti_to_mime(type); - if (mime) results[@(mime)] = result; - } + results[@(mime)] = result; } dispatch_group_leave(promiseGroup); }]; } dispatch_group_notify(promiseGroup, dispatch_get_main_queue(), ^{ + if (_glfw.hints.init.debugRendering) { + NSMutableString *s = [NSMutableString stringWithString:@"Drop file promises fulfilled:"]; + for (NSString *key in results) [s appendFormat:@"\n\t%@ -> %@", key, results[key]]; + debug("%s\n", [s UTF8String]); + } _GLFWwindow *w = NULL; for (_GLFWwindow *ww = _glfw.windowListHead; ww; ww = ww->next) { if (ww->id == wid) { @@ -1768,6 +1838,8 @@ build_uri_list(_GLFWDropData *d) { for (NSString *mime in d->in_progress_drop.pending_requests) { send_data_available_event_on_next_event_loop_tick(wid, [mime UTF8String]); } } } else { + // The drop this belonged to is long gone, discard the fulfilled files + [results release]; NSError *error = nil; [[NSFileManager defaultManager] removeItemAtURL:tempDirURL error:&error]; } @@ -1866,6 +1938,38 @@ void _glfwPlatformEndDrop(GLFWwindow *w UNUSED, GLFWDragOperationType op UNUSED) { free_drop_data((_GLFWwindow *)w); } + +// Transfer ownership of the temporary directory holding the files fulfilled +// from the file promises of the in progress drop to the caller, so that it is +// not deleted when the drop ends. Returns the path to the directory or NULL if +// this drop has no files fulfilled from promises. The returned string is valid +// till the next call to this function. Must be called before the drop is +// finished. +GLFWAPI const char * +glfwCocoaPreserveDroppedFilePromises(GLFWwindow *w) { + static char path_buf[4096]; + path_buf[0] = 0; + @autoreleasepool { + _GLFWwindow *window = (_GLFWwindow *)w; + _GLFWDropData *d = &window->ns.drop_data; + NSDictionary *path_map = d->in_progress_drop.path_map; + if (!d->in_progress_drop.temp_dir || !path_map) return NULL; + bool has_files = false; + for (NSString *mime in path_map) { + if ([path_map[mime] isKindOfClass:[NSURL class]]) { + has_files = true; + break; + } + } + if (!has_files) return NULL; + const char *path = [(NSURL *)d->in_progress_drop.temp_dir fileSystemRepresentation]; + if (!path || !path[0]) return NULL; + snprintf(path_buf, sizeof(path_buf), "%s", path); + d->in_progress_drop.keep_temp_dir = true; + debug("Preserving dropped file promises in: %s\n", path_buf); + } + return path_buf; +} // }}} - (BOOL)hasMarkedText { diff --git a/glfw/glfw.py b/glfw/glfw.py index 03713f1ef..df6b43e3f 100755 --- a/glfw/glfw.py +++ b/glfw/glfw.py @@ -96,6 +96,7 @@ def generate_wrappers(glfw_header: str) -> None: void glfwCocoaSetWindowChrome(GLFWwindow* window, unsigned int color, bool use_system_color, unsigned int system_color,\ int background_blur, unsigned int hide_window_decorations, bool show_text_in_titlebar, int color_space, float background_opacity, bool resizable) void glfwCocoaRegisterMIMETypes(GLFWwindow *window, const char **mimes, size_t count) + const char* glfwCocoaPreserveDroppedFilePromises(GLFWwindow *window) void glfwCocoaSetWindowLevel(GLFWwindow *window, const char *level_spec) const char* glfwGetPrimarySelectionString(GLFWwindow* window, void) int glfwGetNativeKeyForName(const char* key_name, int case_sensitive) diff --git a/kitty/boss.py b/kitty/boss.py index 8915c5aca..faa68a4fd 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -188,6 +188,9 @@ if TYPE_CHECKING: RCResponse = Union[dict[str, Any], None, AsyncResponse] +# How long files dropped onto kitty as macOS file promises are kept alive for, in seconds +DROPPED_FILE_PROMISES_LIFETIME = 600.0 + ThumbnailCallback = Callable[[int, int, bytes, int, int], None] @@ -2202,6 +2205,21 @@ class Boss: if (tab_id := tm.tab_bar.tab_id_at(x, y)) and (tab := self.tab_for_id(tab_id)) and (w := tab.active_window): w.on_drop(drop) + def dropped_file_promises_dir(self, path: str) -> None: + # Called by the C code on macOS with the temporary directory holding the + # files a drop of file promises was fulfilled into. Their paths are what + # is given to the program running in the window, so the files must + # outlive the drop. Keep them around for a while, ensuring they are + # removed even if kitty is killed before that. + self.atexit.rmtree(path) + + def remove_dropped_files(timer_id: int | None) -> None: + import shutil + + shutil.rmtree(path, ignore_errors=True) + + add_timer(remove_dropped_files, DROPPED_FILE_PROMISES_LIFETIME, False) + def on_drag_source_finished( self, was_dropped: bool, was_canceled: bool, accepted_mime_type: str, action: int, data: dict[str, bytes] | None, needs_toplevel_on_wayland: bool ) -> None: diff --git a/kitty/glfw-wrapper.c b/kitty/glfw-wrapper.c index f52be8f63..1c2ca2cb6 100644 --- a/kitty/glfw-wrapper.c +++ b/kitty/glfw-wrapper.c @@ -506,6 +506,9 @@ load_glfw(const char* path) { *(void **) (&glfwCocoaRegisterMIMETypes_impl) = dlsym(handle, "glfwCocoaRegisterMIMETypes"); if (glfwCocoaRegisterMIMETypes_impl == NULL) dlerror(); // clear error indicator + *(void **) (&glfwCocoaPreserveDroppedFilePromises_impl) = dlsym(handle, "glfwCocoaPreserveDroppedFilePromises"); + if (glfwCocoaPreserveDroppedFilePromises_impl == NULL) dlerror(); // clear error indicator + *(void **) (&glfwCocoaSetWindowLevel_impl) = dlsym(handle, "glfwCocoaSetWindowLevel"); if (glfwCocoaSetWindowLevel_impl == NULL) dlerror(); // clear error indicator diff --git a/kitty/glfw-wrapper.h b/kitty/glfw-wrapper.h index c19bef2be..ab80555c4 100644 --- a/kitty/glfw-wrapper.h +++ b/kitty/glfw-wrapper.h @@ -2508,6 +2508,10 @@ typedef void (*glfwCocoaRegisterMIMETypes_func)(GLFWwindow*, const char**, size_ GFW_EXTERN glfwCocoaRegisterMIMETypes_func glfwCocoaRegisterMIMETypes_impl; #define glfwCocoaRegisterMIMETypes glfwCocoaRegisterMIMETypes_impl +typedef const char* (*glfwCocoaPreserveDroppedFilePromises_func)(GLFWwindow*); +GFW_EXTERN glfwCocoaPreserveDroppedFilePromises_func glfwCocoaPreserveDroppedFilePromises_impl; +#define glfwCocoaPreserveDroppedFilePromises glfwCocoaPreserveDroppedFilePromises_impl + typedef void (*glfwCocoaSetWindowLevel_func)(GLFWwindow*, const char*); GFW_EXTERN glfwCocoaSetWindowLevel_func glfwCocoaSetWindowLevel_impl; #define glfwCocoaSetWindowLevel glfwCocoaSetWindowLevel_impl diff --git a/kitty/glfw.c b/kitty/glfw.c index 0afd6245b..5bf8d6347 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -871,6 +871,15 @@ read_drop_data(GLFWwindow *window, GLFWDropEvent *ev) { if (ret == 0) { global_state.drop_dest.num_left--; if (!global_state.drop_dest.num_left) { +#ifdef __APPLE__ + // Files dropped as file promises are fulfilled into a temp dir that is deleted + // when the drop ends. Since their paths are what is given to the program running + // in the window, keep them alive for a while, see Boss.dropped_file_promises_dir() + if (glfwCocoaPreserveDroppedFilePromises) { + const char *promises_dir = glfwCocoaPreserveDroppedFilePromises(window); + if (promises_dir && promises_dir[0]) call_boss(dropped_file_promises_dir, "s", promises_dir); + } +#endif WINDOW_CALLBACK( on_drop, "OOii",