Text view adventures, Part 2

December 5, 2018

By Jeff Johnson

In Part 1 of my multipart text view adventures, I wrote about an NSTextView memory management bug with attachments that could result in "zombie movies". Zombie movies are bad, m'kay. You don't want to see zombie movies. I explained how to make the zombies disappear, and I thought I killed the zombies, but later I realized that zombies are dead already, so killing them is fruitless. Who knew? It turns out that the zombies were merely in hiding, waiting to attack again. The only true way to avoid zombie movies is to never make them in the first place.

Enough with the metaphors, though. Let's get on with the similes. Like, now. When NSTextAttachment switched from QuickTime to AVKit in macOS 10.13, it brought more problems than just zombie movies. AV stands for (among other things) audiovisual, which means that AVKit handles audio as well as video. Try dragging an mp3 file into a TextEdit Rich Text document. It works! Err, for some definition of works. An attachment is created for the mp3 file. However, the attachment is just a strange dark rectangle in the document that doesn't play audio or even have audio controls.

Here's another problem that I discovered by total chance:

  1. In Finder, copy a .mp3 or .mov file
  2. In TextEdit, open a rich text document and paste
  3. Quit TextEdit and Delete the document without saving
  4. In Terminal, $ open "$TMPDIR"com.apple.TextEdit

In step 2, a copy of the audio or video file is created in the temporary directory. This is less than ideal, for a number of reasons. If you delete the document without quitting TextEdit, the temporary file does get deleted automatically, but if you quit TextEdit, the temporary file is left over. This is much less than ideal, and it happens with any app that uses NSTextView.

I explained in Part 1 that NSTextAttachment uses AVPlayerView when attachmentCell returns nil. You can see the implementation of -[NSTextAttachment attachmentCell] and much more with a Terminal command:

$ otool -tV /System/Library/PrivateFrameworks/UIFoundation.framework/UIFoundation

I won't show the disassembly here, but an analysis of it reveals that the method usesTextAttachmentView controls whether NSTextAttachment uses AVKit or QuickTime for non-image attachments such as audio and video. If usesTextAttachmentView returns YES then attachmentCell returns nil, but if usesTextAttachmentView returns NO then -[NSTextAttachment _attachmentCell] gets called to create an attachment cell, using QuickTime when appropriate. Thus, you can subclass NSTextAttachment and override -[NSTextAttachment usesTextAttachmentView] to return NO in order to prevent your NSTextView from using AVKit. When QuickTime is used instead of AVKit, temporary files no longer get created at all in $TMPDIR, which suggests that this behavior is the fault of AVKit and not the fault of NSTextView itself.

Although this workaround for AVKit problems seems attractive prima facie, there are several major pitfalls if you follow that path. First, usesTextAttachmentView is a private method, not public API, so it would not be allowed in the Mac App Store. Second, we have no idea how long the QuickTime fallback will continue to work. It could disappear entirely in macOS 10.15 Nieve Mojave.

I personally took a different approach, which is to dispense entirely with inline previews of audio and video files. Aside from the problems already discussed, inline previews from both AVKit and QuickTime have their own special contextual menus, preventing you from using the standard NSTextView contextual menu on text attachments. The contextual menu was important for my purposes, so I decided to replace inline previews with a file icon. You already see a file icon for attachments of file formats that NSTextAttachment cannot preview, so my approach does not deviate far from expectations. A simple NSTextAttachment subclass will suffice, and does not require any private methods:

@interface JJTextAttachment : NSTextAttachment
@end

@implementation JJTextAttachment {
  NSTextAttachmentCell *_JJTextAttachmentCell;
}

-(id<NSTextAttachmentCell>)attachmentCell {
  if (_JJTextAttachmentCell == nil) {
    NSString *fileType = [self fileType];
    if (fileType == nil)
      fileType = @"public.data";
    NSImage *image = [[NSWorkspace sharedWorkspace] iconForFileType:fileType];
    _JJTextAttachmentCell = [[NSTextAttachmentCell alloc] initImageCell:image];
    [_JJTextAttachmentCell setAttachment:self];
  }
  return _JJTextAttachmentCell;
}

@end

The trick is to replace NSTextAttachment with your subclass in the NSTextView. That's not a problem in text that you control, but it's a problem in an editable text view, where the user can paste in file attachments. What I did was set the NSTextStorageDelegate of the NSTextView and implement a delegate method. Here's some code to do that:

-(void)textStorage:(NSTextStorage *)textStorage willProcessEditing:(NSTextStorageEditActions)editedMask range:(NSRange)editedRange changeInLength:(NSInteger)delta {
  NSUInteger length = editedRange.length;
  if (length == 0)
    return;
  if ((editedMask & NSTextStorageEditedCharacters) != NSTextStorageEditedCharacters)
    return;
  [textStorage enumerateAttribute:NSAttachmentAttributeName inRange:editedRange options:0 usingBlock:^(id value, NSRange range, BOOL *stop) {
    if (value == nil) // Text, not file
      return;
    if (![value isKindOfClass:[NSTextAttachment class]])
      return;
    if ([value isKindOfClass:[JJTextAttachment class]])
      return;
    if ([(NSTextAttachment *)value attachmentCell] != nil)
      return;
    NSFileWrapper *fileWrapper = [(NSTextAttachment *)value fileWrapper];
    if (fileWrapper == nil) {
      NSLog(@"No file wrapper for text attachment: %@", value);
      return;
    }
    JJTextAttachment *attachment = [[JJTextAttachment alloc] initWithFileWrapper:fileWrapper];
    NSAttributedString *attachmentString = [NSAttributedString attributedStringWithAttachment:attachment];
    if (attachmentString == nil) {
      NSLog(@"Cannot create attachment string: %@", fileWrapper);
      return;
    }
    [textStorage replaceCharactersInRange:range withAttributedString:attachmentString];
  }];
}

This gives you an NSTextView that handles audio and video attachments simply and gracefully. We've reached the end of this blog post, which I'll handle simply and gracefully by saying farewell until Part 3.