14

When dragging pictures from the new Photos.app, no URL is passed in the pasteboard as part of the dragging info. My app already correctly handles images passed from e.g. iPhoto, Photo Booth, Aperture,...

I tried dragging pictures from Photos.app: Finder or Pages handle that properly, but not TextEdit or Preview. There seems to be something different about the way Photos.app works with the pictures stored in its library.

charles
  • 11,212
  • 3
  • 31
  • 46

2 Answers2

14

After digging into NSPasteboard and stepping through the app, I realized Photos.app is passing "promised files" in the pasteboard, and found this thread in an Apple's mailing list with some answers: http://prod.lists.apple.com/archives/cocoa-dev/2015/Apr/msg00448.html

Here is how I finally solved it, in the class that handles drag and drop of files into a document. The class is a view controller that handles the usual drag/drop methods because it's in the responder chain.

A convenience method detects wether the sender of a drag has any file-related content:

- (BOOL)hasFileURLOrPromisedFileURLWithDraggingInfo:(id <NSDraggingInfo>)sender
{
    NSArray *relevantTypes = @[@"com.apple.pasteboard.promised-file-url", @"public.file-url"];
    for(NSPasteboardItem *item in [[sender draggingPasteboard] pasteboardItems])
    {
        if ([item availableTypeFromArray:relevantTypes] != nil)
        {
            return YES;
        }
    }
    return NO;
}

I also have a method to extract the URL in the case where it's not a "promised file":

- (NSURL *)fileURLWithDraggingInfo:(id <NSDraggingInfo>)sender
{
    NSPasteboard *pasteboard = [sender draggingPasteboard];
    NSDictionary *options = [NSDictionary dictionaryWithObject:@YES forKey:NSPasteboardURLReadingFileURLsOnlyKey];
    NSArray *results = [pasteboard readObjectsForClasses:[NSArray arrayWithObject:[NSURL class]] options:options];
    return [results lastObject];
}

Here is finally the method used to handle a drop. It's not quite exactly my code, as I simplified the internal handling of dragging into convenience methods that allow me to hide the parts specific for the app. I also have a special class for handling file system events FileSystemEventCenter left as an exercise to the reader. Also, in the case presented here, I only handle dragging one file. You'll have to adapt those parts to your own case.

- (NSDragOperation)draggingEntered:(id <NSDraggingInfo>)sender
{
    if ([self hasFileURLOrPromisedFileURLWithDraggingInfo:sender])
    {
        [self updateAppearanceWithDraggingInfo:sender];
        return NSDragOperationCopy;
    }
    else
    {
        return NSDragOperationNone;
    }
}

- (NSDragOperation)draggingUpdated:(id <NSDraggingInfo>)sender
{
    return [self draggingEntered:sender];
}

- (void)draggingExited:(id <NSDraggingInfo>)sender
{
    [self updateAppearanceWithDraggingInfo:nil];
}

- (void)draggingEnded:(id <NSDraggingInfo>)sender
{
    [self updateAppearanceWithDraggingInfo:nil];
}

- (BOOL)prepareForDragOperation:(id <NSDraggingInfo>)sender
{
    return [self hasFileURLOrPromisedFileURLWithDraggingInfo:sender];
}

- (BOOL)performDragOperation:(id <NSDraggingInfo>)sender
{
    // promised URL
    NSPasteboard *pasteboard = [sender draggingPasteboard];
    if ([[pasteboard types] containsObject:NSFilesPromisePboardType])
    {
        // promised files have to be created in a specific directory
        NSString *tempPath = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSUUID UUID] UUIDString]];
        if ([[NSFileManager defaultManager] createDirectoryAtPath:tempPath withIntermediateDirectories:NO attributes:nil error:NULL] == NO)
        {
            return NO;
        }

        // the files will be created later: we keep an eye on that using filesystem events
        // `FileSystemEventCenter` is a wrapper around FSEvent
        NSArray *filenames = [sender namesOfPromisedFilesDroppedAtDestination:[NSURL fileURLWithPath:tempPath]];
        DLog(@"file names: %@", filenames);
        if (filenames.count > 0)
        {
            self.promisedFileNames = filenames;
            self.directoryForPromisedFiles = tempPath.stringByStandardizingPath;
            self.targetForPromisedFiles = [self dropTargetForDraggingInfo:sender];
            [[FileSystemEventCenter defaultCenter] addObserver:self selector:@selector(promisedFilesUpdated:) path:tempPath];
            return YES;
        }
        else
        {
            return NO;
        }
    }

    // URL already here
    NSURL *fileURL = [self fileURLWithDraggingInfo:sender];
    if (fileURL)
    {
        [self insertURL:fileURL target:[self dropTargetForDraggingInfo:sender]];
        return YES;
    }
    else
    {
        return NO;
    }
}

- (void)promisedFilesUpdated:(FDFileSystemEvent *)event
{
    dispatch_async(dispatch_get_main_queue(),^
     {
         if (self.directoryForPromisedFiles == nil)
         {
             return;
         }

         NSString *eventPath = event.path.stringByStandardizingPath;
         if ([eventPath hasSuffix:self.directoryForPromisedFiles] == NO)
         {
             [[FileSystemEventCenter defaultCenter] removeObserver:self path:self.directoryForPromisedFiles];
             self.directoryForPromisedFiles = nil;
             self.promisedFileNames = nil;
             self.targetForPromisedFiles = nil;
             return;
         }

         for (NSString *fileName in self.promisedFileNames)
         {
             NSURL *fileURL = [NSURL fileURLWithPath:[self.directoryForPromisedFiles stringByAppendingPathComponent:fileName]];
             if ([[NSFileManager defaultManager] fileExistsAtPath:fileURL.path])
             {
                 [self insertURL:fileURL target:[self dropTargetForDraggingInfo:sender]];
                 [[FileSystemEventCenter defaultCenter] removeObserver:self path:self.directoryForPromisedFiles];
                 self.directoryForPromisedFiles = nil;
                 self.promisedFileNames = nil;
                 self.targetForPromisedFiles = nil;
                 return;
             }
         }
    });
}
charles
  • 11,212
  • 3
  • 31
  • 46
  • Thanks for this answer. I've implemented your solution and am getting files being copied into the temp path by the photos app but unfortunately it seems to be converting them all to JPG (rather than retaining them as PNGs) this is important for my application. I wonder if anyone else has had the same problem and if there is any way of specifying "don't convert the files"? `hasFileURLOrPromisedFileURLWithDraggingInfo` seems to be deprecated but I can't find a solution... The promises API seems very old and very broken IMO so my current 'solution' is to ditch support for it... – Sam Oct 14 '15 at 08:41
  • @Sam I believe this is specific to Photos. It returns JPG no matter what the original format of the picture is. Try to drag the picture to the Finder or into Preview and see what happens. – charles Dec 14 '15 at 20:44
  • Just to clarify, Sam: I meant that dragging from Photos to Finder would result in a jpg. The fact that it is a jpg is independent of the promise API: it's just what Photos does. It won't give you the original file. Even if it did not use the promise API, the app could decide to hand over a jpg (but it would have to generate it on the fly and have it ready before the drag starts). I have not tested that, just my expectations. – charles Dec 15 '15 at 21:46
  • ah ok. thanks very much for clarifying. sounds like staying well clear of the photos app is worthwhile ;) – Sam Dec 16 '15 at 10:43
4

Apple made this a bit easier in 10.12 with the NSFilePromiseReceiver. It's still a long a fiddly process, but a little less so.

Here's how I'm doing it. I've actually split this out into an extension, but I've simplified it for this example.

    override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {

        let pasteboard: NSPasteboard = sender.draggingPasteboard()

            guard let filePromises = pasteboard.readObjects(forClasses: [NSFilePromiseReceiver.self], options: nil) as? [NSFilePromiseReceiver] else {
                return
            }
            var images = [NSImage]()
            var errors = [Error]()

            let filePromiseGroup = DispatchGroup()
            let operationQueue = OperationQueue()
            let newTempDirectory: URL
            do {
        let newTempDirectory = (NSTemporaryDirectory() + (UUID().uuidString) + "/") as String
        let newTempDirectoryURL = URL(fileURLWithPath: newTempDirectory, isDirectory: true)

        try FileManager.default.createDirectory(at: newTempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
            }
            catch {
                return
            }

            filePromises.forEach({ filePromiseReceiver in

                filePromiseGroup.enter()

                filePromiseReceiver.receivePromisedFiles(atDestination: newTempDirectory,
                                                         options: [:],
                                                         operationQueue: operationQueue,
                                                         reader: { (url, error) in

                                                            if let error = error {
                                                                errors.append(error)
                                                            }
                                                            else if let image = NSImage(contentsOf: url) {
                                                                images.append(image)
                                                            }
                                                            else {
                                                                errors.append(PasteboardError.noLoadableImagesFound)
                                                            }

                                                            filePromiseGroup.leave()
                })

            })

            filePromiseGroup.notify(queue: DispatchQueue.main,
                                    execute: {
// All done, check your images and errors array

            })
}
Mark Bridges
  • 8,228
  • 4
  • 50
  • 65
  • I used your method. However, the operation won't run until I manually close the app. I mean, until I close my app, the images won't be shown in the cache folder. Any suggestion? – Owen Zhao Dec 08 '17 at 01:27
  • Interesting. Sounds like deadlock. Are you reusing that same operation queue somewhere else? Are you hanging somewhere by trying to read the file before it’s written? – Mark Bridges Dec 08 '17 at 07:37
  • Your method works with Safari. The image from Safari appears in the cache folder immediately. However, with Photos, the images won't be shown until I manually quit my app. I thought it maybe a bug of Photos. However, Mail.app can get the images from Photos flawlessly. So I request a technical support from Apple. – Owen Zhao Dec 08 '17 at 09:29
  • I'm using this technique with the Photos app and it works for me. That said, this isn't quite just copy and pasted from my own app. I'm pretty sure it's going to end up being a subtle threading issue you're experiencing there. – Mark Bridges Dec 08 '17 at 09:54
  • It works with Safari. So it must also work with Photos, Right? But my situation is just opposite. If there is anything wrong, Safari shouldn't have worked either. – Owen Zhao Dec 08 '17 at 10:10
  • I get `[Error Domain=NSCocoaErrorDomain Code=3072 "The operation was cancelled."]` when I try this approach for an e-mail drag & drop operation. – Alex Dec 24 '17 at 12:25
  • I have used your code to try and drop an email from Apple Mail. It's working quite well, but although I get the file instantly the code times out for 1 minute and then returns an error and the url variable with the name of the folder duplicated. eg /users/andrew/temporary/temporary. To avoid posting my answer, my code is posted at https://stackoverflow.com/questions/47991603/receive-promised-e-mail-in-macos-10-12 – iphaaw Jan 28 '18 at 22:16
  • This did work. I suspect Apple have broken something. I'm now getting this error. ```*** Assertion failure in -[NSFilePromiseReceiver receivePromisedFilesAtDestination:options:operationQueue:reader:], /BuildRoot/Library/Caches/com.apple.xbs/Sources/AppKit/AppKit-1561.20.106/AppKit.subproj/NSFilePromiseReceiver.m:329``` – Mark Bridges Feb 25 '18 at 18:08