Search code examples
macosappkitfoundation

I want to require a password when opening/creating a NSDocument. Where to put the prompt?


I'm really unfamiliar with macOS development and are trying to figure out the right way to do this. Scenario: My application works with encrypted documents. Those are cross-platform, so I can't change the encryption mechanism (to e.g., use something provided by the OS directly). I also want to create an iOS app later, and share as much code as possible.

The flow is intended to be this:

  1. Either "Open" or "New" a new Document
  2. Prompt the user for a password
  3. (If opening a Document, verify that the password is good, otherwise repeat step 2 until good or cancelled)
  4. Display the Document Window

So I have these classes:

  • MyEncryptedDocument, subclassing NSDocument
  • NSDocumentController, just using the default
  • NSWindowController, just using the default
  • NSWindow, just using the default
  • MyViewController, subclassing NSViewController

This is all contained in a single main.storyboard (thinking about splitting, but first want to figure out correct architecture):

main.storyboard structure

I have implemented read(from data: Data, ofType typeName: String) in MyEncryptedDocument, to just read the content as a byte array. Now, here's where I would display the password prompt, but it seems that the NSDocument class isn't the right place for that - for starters, I don't have a WindowController, and windowControllers is empty (I assume that makeWindowControllers gets called afterwards).

I've been thinking of subclassing either NSWindowController or NSWindow, but then I wonder where the proper place would be for the password prompt? awakeFromNib in the WindowController doesn't have the Document yet, though I could assign it via makeWindowControllers.

This leaves me with these questions:

  • Should MyEncryptedDocument actually deal with just the binary, encrypted data? Or should it handle the password and decrypted business object?
  • Should the password prompt live in the WindowController, Window, ViewController, Document, DocumentController, or elsewhere?
  • What are the proper methods to implement/override if I want to use pretty much all of the macOS features that NSDocument already does (Autosave, iCloud support, versioning etc.) but only want to intercept the open/new process to ask the user for a password?

I'm OK with either Swift or Objective-C since I care more about the "Where" and less about the exact "How".


Solution

  • Here's how I've implemented it now:

    • Create a Subclass of NSDocumentController
    • In the AppDelegate, instantiate that class - that is enough to set it as the DocumentController for the application (there can be only one)
    • In the subclass, set up handlers for makeUntitledDocumentOfType:error: and makeDocumentWithContentsOfURL:ofType:error:
    • There, I can now create the Dialog to ask for the password and either create the (decrypted) document, or return an error.
    • MyEncryptedDocument (subclass of NSDocument) requires a password in its init/constructor. This is used in the overridden readFromData:ofType:error: and dataOfType:error: to load/decrypt and save/encrypt the data

    The DocumentController really seems to be the place that should deal with this in my opinion, since the password/encryption is more a pipeline-concern than a concern of the actual document or any of the UI. Overall, this "feels" right to me as an inexperienced macOS dev. I'm not sure if NSAlert is the correct class for the dialog; looking at Apple's Guidelines I think I should create my own NSPanel or NSWindow. But that is a concern for later.

    In Xamarin C# code, the class looks like this:

    public class MyEncryptedDocumentController : NSDocumentController
    {
        public MyEncryptedDocumentController()
        {
        }
    
        // makeUntitledDocumentOfType:error:
        public override NSObject MakeUntitledDocument(string typeName, out NSError error)
        {
            return LoadOrCreateDocument(typeName, null, out error);
        }
    
        // makeDocumentWithContentsOfURL:ofType:error:
        public override NSObject MakeDocument(NSUrl url, string typeName, out NSError outError)
        {
            return LoadOrCreateDocument(typeName, url, out outError);
        }
    
        private MyEncryptedDocument LoadOrCreateDocument(string typeName, NSUrl url, out NSError error)
        {
            error = null;
            using (var sb = NSStoryboard.FromName("PasswordView", null))
            using (var ctrl = sb.InstantiateControllerWithIdentifier("Password View Controller") as PasswordViewController)
            using (var win = new NSAlert())
            {
                win.MessageText = "Please enter the Password:";
                //win.InformativeText = "Error message goes here.";
                win.AlertStyle = NSAlertStyle.Informational;
                win.AccessoryView = ctrl.View;
    
                var btnOK = win.AddButton("OK");
                var btnCancel = win.AddButton("Cancel");
    
                var res = win.RunModal();
                var pw = ctrl.Password;
    
                if (res == (int)NSAlertButtonReturn.First)
                {
                    var doc = new MyEncryptedDocument(pw);
                    if (url != null)
                    {
                        if (!doc.ReadFromUrl(url, typeName, out error))
                        {
                            // Could check if error is a custom "Wrong Password"
                            // and then re-open the Alert, setting the Informational Text
                            // to something like "wrong password"
                            return null;
                        }
                    }
    
                    return doc;
                }
    
                // MyEncryptedDocument.Domain is a NSString("com.mycompany.myapplication");
                // MyErrorCodes is just a custom c# enum
                error = new NSError(MyEncryptedDocument.Domain, (int)MyErrorCodes.PasswordDialogCancel);
                return null;
            }
        }
    }
    

    PasswordViewController is a pretty simple subclass of NSViewController:

    public partial class PasswordViewController : NSViewController
    {
        public string Password { get => tbPassphrase?.StringValue ?? ""; }
    
        public PasswordViewController(IntPtr handle) : base(handle)
        {
        }
    }
    

    tbPassphrase is the outlet for the text box in the view (@synthesize tbPassphrase = _tbPassphrase; in the .h file). The storyboard is simple scene with a viewController:

    <viewController storyboardIdentifier="Password View Controller" id="5LL-3u-LyJ" customClass="PasswordViewController" sceneMemberID="viewController">
        <view key="view" id="yoi-7p-9v6">
            <rect key="frame" x="0.0" y="0.0" width="315" height="22"/>
            <autoresizingMask key="autoresizingMask"/>
            <subviews>
                <secureTextField identifier="tfPassphrase" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="YmM-nK-9Hb">
                    <rect key="frame" x="0.0" y="0.0" width="315" height="22"/>
                    <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
                    <secureTextFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" drawsBackground="YES" usesSingleLineMode="YES" id="ChX-i5-luo">
                        <font key="font" metaFont="system"/>
                        <color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
                        <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
                        <allowedInputSourceLocales>
                            <string>NSAllRomanInputSourcesLocaleIdentifier</string>
                        </allowedInputSourceLocales>
                    </secureTextFieldCell>
                </secureTextField>
            </subviews>
        </view>
        <connections>
            <outlet property="tbPassphrase" destination="YmM-nK-9Hb" id="sCC-Ve-8FO"/>
        </connections>
    </viewController>