Adding a File Type selector to the Sandboxed MacOS SaveAs panel.

In my quest to bring a very old MacOS app written in Objective-C into the modern age of Swift and Storyboards, I discovered that my file export no longer worked. The problem turned out to be that Apples developers went to quite a bit of trouble to prevent malicious developers from writing files to places the user doesn’t know about. To achieve this the Apple developers removed any ability to programmatically change the name (file extension) of the file being saved and also made sure that NSSavePanel.url is a secure URL that cannot be changed after the save panel runs.

Since being able to export my document to an image file of a selected type is central to the functionality I am selling, this was a big problem. Thus begins my intellectual odyssey to find a way to fox my way around the sandboxed Cocoa SaveAs panel.

In this post I am going to first explain how I determined the best approach to solving the problem and then I am going to walk you through how to write the code. Since there are many wonderful tutorials on how to do storyboards, document based macOS apps, bindings and general Swift language programming, I am not going to delve into those subjects in any great detail. If you would like to see more of that kind of content from me please ask.

First to sum up what the problems are:

  • NSSavePanel does not allow the developer to programmatically change the file name when the panel is running. This means that you can’t change the file extension if the user selects a file type.
  • NSSavePanel’s url property is a secure URL which doesn’t allow its file extension to be altered.
  • NSSavePanel does not appear to have any callback or delegate functions that tell us that the file type has changed.

… and here are some things that we can observe or infer:

  • If you provide a list of export file type uti’s (I’ll get to that later) the document “Save As…” provides it’s own type selector that CAN change the file extension. 🤔
  • From this we can infer that you probably can’t just launch an NSSavePanel with an accessory view and expect that it will work because NSDocument is doing some private setup.
  • Using the view debugging inspector we do, indeed, see that there seems to be an accessory view with a popup button in it that is configured to do what we want; we just don’t have any way to know what file type the user selected until the document write to url function is called which is too late for my purposes.

We would really like to be able to present our own export “Save As…” panel with our own, more complete, accessory view that lets the user select a file type, resolution if it’s a bitmap type, compression if it’s a compressible type and so on based on the type of file the user wants to export. We know that Apples “Save As…” code does the file type selector part but we don’t yet know how; so let’s find out.

Step 1 Configure your document to provide exportable uti’s

Before you can do anything at all you need to make sure that your applications document knows about your exportable types. If you haven’t already done this here’s the basics.

  • Add the uti’s for your exportable types to the info.plist file. This is the “Exportable Type Identifiers” array (UTExportedTypeDeclarations) and contains an array of types that each describe a type eg. “public.jpeg”, with an extension like “jpg”.
  • In my code I like to create constants for things like types by extending String with a set of ‘static let someType: String = “…”‘ just to make my code cleaner.
  • Override NSDocuments “readableTypes”, “writableTypes”, “isNativeType” and “prepareSavePanel” functions. “func writableTypes(…) is the really important one that should match what’s in the info.plist files uti’s “,
  • For now we will use the prepareSavePanel function to examine the popup file types button that the save as panel is preconfigured with when there are multiple exportable types.

For the moment we will just use the “prepareSavePanel” function to examine the accessory view that is already attached to the save panel by Apple. We also need some help to find the popup button and print information that we want about the button.

We can find the popup by putting a “var popup: NSPopUpButton” computed var on NSView and overriding it on NSPopUpButton. In the NSView version you can see that we just recursively call “popup” which will descend the hierarchy until it hits an NSPopUpButton which just returns self. Easy peasy.

In our extension of the document class we override these:


extension String {
    static let mdUTI: String = "MyDocument"
    static let pdfUTI: String = String(kUTTypePDF)
    static let gifUTI: String = String(kUTTypeGIF)
    static let pngUTI: String = String(kUTTypePNG)
    static let tiffUTI: String = String(kUTTypeTIFF)
    static let jpegUTI: String = String(kUTTypeJPEG)
}

extension MyDocument {
    
    open override class var readableTypes: [String] {
        return [.mdUTI]
    }
    
    open override func writableTypes(for saveOperation: NSDocument.SaveOperationType) -> [String] {
// for now just return the full set of types
        return [.jpegUTI, .tiffUTI, .pngUTI, .pdfUTI, .gifUTI]
    }
    
    open override class func isNativeType(_ type: String) -> Bool {
        return type == .mdUTI
    }
    
}

Step 2 Add a utility function to find the popup button

Since I don’t have any need to find anything but that single popup button in the Apple supplied accessory view, I don’t need a generic, “find anything” function. So I’ll keep this simple.

 extension NSView {
    @objc var popup: NSPopUpButton? {
        return self.subviews.compactMap{ $0.popup }.first
    }
}


extension NSPopUpButton {
    override var popup: NSPopUpButton? {
        return self
    }
    
// print out the target, action, bindings, and menu items
    func printInfo() {
        print("\n=========== NSPopUpButton Info =============")
        print("target: \(self.target)  selector: \(self.action)")
print("bindings: ")
        for binding in self.exposedBindings {
print("----------------------------------\n")
print("\(binding.rawValue)  info: \(infoForBinding(binding))")
}
        print("---------------------------------")
        for item in self.itemArray {
            print("title: \(item.title)   representedObject: \(item.representedObject)")
        }
        print("==========================================\n")
    }
}

Step 3 Override the prepareSavePanel function

Here we just want to list the contents of the popup button. We will fill this in with code to create and add our own accessory view later but for now we are just finding things out.

extension MyDocument {
    open override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
        if let accessoryView: NSView = savePanel.accessoryView,
           let popup: NSPopUpButton = accessoryView.popup {
            
            // print everything that might be going on with this
// button using the helper functions above
            popup.printInfo()
        }
        
        return true
    }
}

Step 4 Run the app and do a Save As in the file menu

And the results of calling “Save As…” in the file menu are:


=============== NSPopUpButton Info ======================
target: Optional(<MyDocument: 0x7fa79cc07ca0>) 
selector: Optional(changeSaveType:) 
bindings: 
selectedValue  info: nil
selectedObject  info: nil
selectedTag  info: nil
enabled  info: nil
contentObjects  info: nil
hidden  info: nil
contentValues  info: nil
selectedIndex  info: nil
content  info: nil
*** there are others but these were the important ones ***
---------------------------------
title: JPEG image   representedObject: Optional(public.jpeg)
title: TIFF image   representedObject: Optional(public.tiff)
title: PNG image   representedObject: Optional(public.png)
title: PDF document   representedObject: Optional(com.adobe.pdf)
title: GIF image   representedObject: Optional(com.compuserve.gif)
================================================================

As you can see in the above listing the popup button has a target of MyDocument, an action selector of “changeSaveType:”, no bindings and at the bottom a set of menu items with titles and (importantly) represented objects of the file uti’s. Interestingly we cannot find changeSaveType as a public function on NSDocument which means that the Apple engineers don’t want us calling that function directly. Luckily they handed us the selector in the popup button so we don’t actually need to do anything crazy to use it.

Important Note: As has been suggested by a number of people it is possible to fake the Objective-C runtime into creating a selector for a private function like “changeSaveType:” through the use of a protocol. The problem with this approach is that Apple is not obliged to announce that they are changing what private functions they call on what target so a future version of the frameworks could break your code. For that reason I have chosen to simply “capture” the target and action of the file type selection popup that Apple has supplied which should work even if Apple changes the target and/or action of the selector popup. Apple could, in the future, decide to do something really kooky like use a custom popup that doesn’t use the target/action mechanism but for now this works nicely.

Putting it all together

So, we now know that Apple’s “Save As…” code will provide us with an NSSavePanel configured with a simple accessory view that has an NSPopUpButton in it that is configured to call a private function called “changeSaveType:” whenever the user selects a file type. Without doing anything fancy we can exploit Apples code to build our own accessory view that lets us select an export file type and update any things like image resolution pickers etc based on selected file type.

In my case I want to ensure that Save As… only saves my native document type in a new location and only shows exportable image types when I select Export… in the file menu so we’ll start with that.

First, instead of just launching an NSSavePanel myself I now need to do a document.saveAs instead. I won’t go into a lot of details here other than to list the basic steps since I’m assuming that you know how to set up storyboards and work with basic document save functions. I’m also not going to go into any detail on doing an accessory view in a storyboard or how to use bindings (which you should) since there are some very good tutorials on the subject at Ray Wenderlich as well as others.

  • Add an “isExporting” boolean property to the NSDocument subclass.
  • Add an “@IBAction func exportDocument(_ sender: Any)” or it’s objective-c equivalent to your subclass of NSDocumentController.
  • Implement exportDocument so that it gets the current document that is being exported and sets its isExporting flag to true and then calls the documents saveDocumentAs function.
  • Connect the file menu -> “Export …” item in the main menu xib or storyboard to the exportDocument action on your NSDocumentController subclass.
  • Change the writableTypes function that we modified earlier to return just our document type unless the save operation is saveAs and isExporting is set to true.
  • Create a new subclass of NSViewController called something like ExportAccessoryViewController and add it to a new or existing storyboard. Make sure to give the ExportAccessoryViewController a storyboard id in the storyboard.
  • Connect your File Type popup in the storyboard to the popup IBOutlet in the view controller; but you can just “bind” the other controls directly to your model properties.
  • Connect your File Type popup’s action to the ExportAccessoryViewControllers changeExportFileType: IBAction.

The code for the ExportAccessoryViewController is listed below. Note that the only thing that is connected as an IBOutlet is the popup button which we need to do some configuring on. All the other interesting things in the storyboard are connected to the model object with Cocoa bindings; this allows good MVC architecture by limiting the view controller to just setting up UI.

class ExportAccessoryViewController: NSViewController {

// Make the view controller responsible for creating
// an instance of itself from the storyboard so that
// you can get an instance with
// ExportAccessoryViewController.instance rather than
// using code that's somewhere else
    class var instance: ExportAccessoryViewController? {
        let sb: String = "ExportAccessoryViewController"
        let vc: String = "ExportAccessoryViewController"
        let storyboard: NSStoryboard = NSStoryboard(name: sb, bundle: nil)
        guard let controller: ExportAccessoryViewController = storyboard.instantiateController(withIdentifier: vc) as? ImageExportAccessoryViewController else {
            return nil
        }
        controller.loadView()
        return controller
    }
    
// our file type selection popup button and
// the target and action selector belonging
// to Apples file type selection popup.
    @IBOutlet var popup: NSPopUpButton!
    fileprivate var fileTypeSelector: Selector?
    fileprivate var fileTypeSelectorTarget: AnyObject?
    
    /// Change the file type for the image export
    ///
    /// This is called by the popup button when the user selects a file type
    /// - Parameter sender: The NSPopUpButton that chooses the file type
    @IBAction func changeExportFileType(_ sender: NSPopUpButton) {
        guard let item: NSMenuItem = sender.selectedItem,
              let uti: String = item.representedObject as? String else { return }


        // update our own type to activate the bindings to update
        // the accessory view ui to present options for the type
        self.model.type = uti
        
        // Pass this along to the target/selector that we extracted from
        // Apples filetype popup so that Apples code will correctly change
// the file type and file name extension in the document.
        _ = self.fileTypeSelectorTarget?.perform(self.fileTypeSelector, with: sender)
    }

// This is a model that has all of the file export properties
// like file type, image resolution, compression, wether the
// export should just be the selected objects in the document
// or the whole document etc. All of the UI in the accessory
// view is bound to the properties in the model rather than to
// this accessory view controller.
    @objc dynamic let model: ImageExportModel = ImageExportModel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
// be sure to remove existing items before adding new ones
        self.popup.removeAllItems()

// I'm getting a list of items from my model but you could
// also get them from Apples popup.
        for item in self.model.menuItems {
            self.popup.addItem(withTitle: item.title)
            self.popup.lastItem?.representedObject = item.representedObject
        }
    }
    
    override func viewWillAppear() {
        super.viewWillAppear()
        
        // make sure to trigger a file type change on the document
        // so that the ui is correct.
        self.popup.select( self.popup.item(at: 0) )
        self.changeExportFileType(self.popup)
    }
    
    /// Attach this controllers view as an accessory view
    ///
    /// This view provides controls for the exported image type, resolution, transparency etc.  It is
    /// important to note that the popup button must be configured with menu items with a title and
    /// a represented object that has the image type uti.
    /// - Parameters:
    ///   - panel: the save panel being added to
    ///   - document: the document that is being exported
    @objc func attach( to panel: NSSavePanel, for document: DoodleCADDoc ) {
        self.model.document = document
        
        // the accessory view that the save panel already has
        // should have a popup configured to do the file type
        // selection; we are going to extract the target and
        // selector to use as the passthrough after completing
// our own changeExportFileType function
        if let popup: NSPopUpButton = panel.accessoryView?.popup {
            self.fileTypeSelectorTarget = popup.target
            self.fileTypeSelector = popup.action
        }
        
        self.savePanel.allowsOtherFileTypes = false
        self.savePanel.isExtensionHidden = false
        self.savePanel.canCreateDirectories = true
        self.savePanel.nameFieldStringValue = (document.displayName as NSString).deletingPathExtension
        self.savePanel.accessoryView = self.view
        self.savePanel.level = NSWindow.Level.modalPanel
        self.savePanel.delegate = self.model
    }
}

As promised we are going to change some of our code that we wrote before so that it will actually work for a custom export accessory view. Below we change the “prepareSavePanel” code to actually put our accessory view into the panel in place of the one Apple put there by calling the ExportAccessoryViewController’s attach function to do the work.

extension MyDocument {
    
    open override func writableTypes(for saveOperation: NSDocument.SaveOperationType) -> [String] {
        if saveOperation == .saveAsOperation && self.isExportingImage {
            return [.jpegUTI, .tiffUTI, .pngUTI, .pdfUTI, .gifUTI]
        }
        return [.ddcUTI]
    }


    open override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
// if we're not exporting an image bail out now
        if !self.isExportingImage { return true }

// get the ExportAccessoryViewController or bail out if
// it returns nil
        guard let controller: ExportAccessoryViewController = ExportAccessoryViewController.instance else {
            return true
        }

        controller.attach(to: savePanel, for: self)
        return true
    }

}

So to sum things up.

  • Override functions in NSDocument to provide the desired export types when the document is being exported.
  • Override the NSDocuments prepareSavePanel function to extract the target and action from Apples file type selector button and then attach your own file type selection accessory view.
  • In your filetype selection code do whatever you need to do with the new filetype and then use the target and action selector from Apples popup to let Apples code to do what it needs to do for file type selection.