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",