objective-cmacoscore-datanspersistentdocument

NSPersistentDocument fails when saving a previously locked document


When opening a locked file using my NSPersistentDocument subclass I get the following message in the console:

Attempt to add read-only file at path [URL] read/write. Adding it read-only instead. This will be a hard error in the future; you must specify the NSReadOnlyPersistentStoreOption.

The document window title is '(document name) - Locked'. After the user unlocks it, makes a change and then attempts to save, the save fails with the error

An error occurred while saving.

It seems that NSPersistentDocument fails to recognize that the user has unlocked the document and doesn't reopen it in read/write mode. Is this a bug in NSPersistentDocument or am I missing something here?

I am not overriding any of the file I/O methods in NSPersistentDocument.


Solution

  • Ah, ok automatic file locking.

    That happens for auto-save documents not accessed in a while.

    The typical approach is to notice the lock before creating the core data stack and put up a dialog asking the user to unlock the file.

    If they agree to unlock the file, you simply unlock it and run as normal.

    If they don't agree to unlock it, you copy it or open it readonly. Of course, you could simply bypass the user's preference and automatically unlock the file anyway, but that's probably not very nice.

    Here is a category that should help you determine if a file is locked, and also lock/unlock the file.

    Note, that this is entirely separate from the files mode being changed to read-only, but you can handle it in a similar manner.

    Category interface

    @interface NSFileManager (MyFileLocking)
    - (BOOL)isFileLockedAtPath:(NSString *)path;
    - (BOOL)unlockFileAtPath:(NSString*)path error:(NSError**)error;
    - (BOOL)lockFileAtPath:(NSString*)path error:(NSError**)error;
    @end
    

    Category implementation

    @implementation NSFileManager (MyFileLocking)
    - (BOOL)isFileLockedAtPath:(NSString *)path {
        return [[[self attributesOfItemAtPath:path error:NULL]
                 objectForKey:NSFileImmutable] boolValue];
    }
    
    - (BOOL)unlockFileAtPath:(NSString*)path error:(NSError**)error {
        return [self setAttributes:@{NSFileImmutable:@NO}
                      ofItemAtPath:path
                             error:error];
    }
    
    - (BOOL)lockFileAtPath:(NSString*)path error:(NSError**)error {
        return [self setAttributes:@{NSFileImmutable:@YES}
                      ofItemAtPath:path
                             error:error];
    }
    @end
    

    Then, you can call [[NSFileManager defaultManager] isFileLockedAtPath:path] to determine if it is locked, and if it is, throw up a dialog asking the user what to do about it. You can then unlock it and open the stack as normal, or leave it locked and open the stack read-only, which will prevent saves from changing the file store.

    Note that you can also monitor the file, and know when it changes from locked/unlocked and respond accordingly.


    For Apple's guidelines on this, see https://developer.apple.com/library/mac/documentation/DataManagement/Conceptual/DocBasedAppProgrammingGuideForOSX/StandardBehaviors/StandardBehaviors.html

    EDIT

    Ok. I would have liked for NSPersistentDocument to replicate the behavior in NSDocument - where the prompt to unlock comes only when an edit is attempted. What you're saying is that there is no such feature in NSPersistentDocument? – Aderstedt

    OK. I thought you were wanting to ask the user to unlock it so that it could be opened read/write.

    If you want to "go with the flow" and open it read-only when necessary, then you should add a little customization to your NSPersistentDocument subclass.

    First, you want to add a little state to keep track of whether or not the original options specified a read-only file.

    @implementation MyDocument {
        BOOL explicitReadOnly;
    }
    

    Then, you will want a couple of utility methods...

    - (NSDictionary*)addReadOnlyOption:(NSDictionary*)options {
        NSMutableDictionary *mutable = options ? [options mutableCopy]
                                               : [NSMutableDictionary dictionary];
        mutable[NSReadOnlyPersistentStoreOption] = @YES;
        return [mutable copy];
    }
    
    - (NSDictionary*)removeReadOnlyOption:(NSDictionary*)options {
        NSMutableDictionary *mutable = options ? [options mutableCopy]
                                               : [NSMutableDictionary dictionary];
        [mutable removeObjectForKey:NSReadOnlyPersistentStoreOption];
        return [mutable copy];
    }
    

    Next, you want to provide your own persistent store coordinator configuration code. This allows you to provide the read-only option to the store when you create it. This method is automatically called when you build your document, all you need to do is provide an override implementation.

    - (BOOL)configurePersistentStoreCoordinatorForURL:(NSURL *)url
                                               ofType:(NSString *)fileType
                                   modelConfiguration:(NSString *)configuration
                                         storeOptions:(NSDictionary<NSString *,id> *)storeOptions
                                                error:(NSError * _Nullable __autoreleasing *)error {
        explicitReadOnly = [storeOptions[NSReadOnlyPersistentStoreOption] boolValue];
        if (![[NSFileManager defaultManager] isWritableFileAtPath:url.path]) {
            storeOptions = [self addReadOnlyOption:storeOptions];
        }
        return [super configurePersistentStoreCoordinatorForURL:url
                                                         ofType:fileType
                                             modelConfiguration:configuration
                                                   storeOptions:storeOptions
                                                          error:error];
    }
    

    Also, notice that NSPersistentDocument implements the NSFilePresenter protocol. Thus, you can override a method and be notified whenever the file content or attributes are changed. This will notify you for any change to the file, including lock/unlock from within your application, the Finder, or any other mechanism.

    - (void)presentedItemDidChange {
        [self ensureReadOnlyConsistency];
        [super presentedItemDidChange];
    }
    

    We then want to ensure that our persistent store remains consistent with the read-only properties of the file.

    Here is one implementation, that just changes the store's readOnly property.

    - (void)ensureReadOnlyConsistency {
        NSURL *url = [self presentedItemURL];
        BOOL fileIsReadOnly = ![[NSFileManager defaultManager] isWritableFileAtPath:url.path];
    
        NSPersistentStoreCoordinator *psc = self.managedObjectContext.persistentStoreCoordinator;
        [psc performBlock:^{
            NSPersistentStore *store = [psc persistentStoreForURL:url];
            if (store) {
                if (fileIsReadOnly) {
                    if (!store.isReadOnly) {
                        store.readOnly = YES;
                    }
                } else if (!explicitReadOnly) {
                    if (store.isReadOnly) {
                        store.readOnly = NO;
                    }
                }
            }
        }];
    }
    

    This works, but has one little hangup. If the store is originally opened with read-only options, then the very first time the readOnly attribute is set to NO, that first save throws (actually, it's the obtainPermanentIDsForObjects:error: call. Core data appears to catch the exception, but it is logged to the console.

    The save continues, and nothing seems amiss. All the objects get saved, and the object IDs are properly obtained and recorded as well.

    So, there is nothing that does not work that I can tell.

    However, there is another more draconian option, but it avoids the aforementioned "issue." You can replace the store.

    - (void)ensureReadOnlyConsistency {
        NSURL *url = [self presentedItemURL];
        BOOL fileIsReadOnly = ![[NSFileManager defaultManager] isWritableFileAtPath:url.path];
    
        NSPersistentStoreCoordinator *psc = self.managedObjectContext.persistentStoreCoordinator;
        [psc performBlock:^{
            NSPersistentStore *store = [psc persistentStoreForURL:url];
            if (store) {
                if (fileIsReadOnly != store.isReadOnly) {
                    NSString *type = store.type;
                    NSString *configuration = store.configurationName;
                    NSDictionary *options = store.options;
                    if (fileIsReadOnly) {
                        options = [self addReadOnlyOption:options];
                    } else if (!explicitReadOnly) {
                        options = [self removeReadOnlyOption:options];
                    }
    
                    NSError *error;
                    if (![psc removePersistentStore:store error:&error] ||
                        ![psc addPersistentStoreWithType:type
                                           configuration:configuration
                                                     URL:url
                                                 options:options
                                                   error:&error]) {
                        // Handle the error
                    }
                }
            }
        }];
    }
    

    Finally, note that the notification happens when the operating system notices that the file has changed. When the file is locked/unlocked from within your application, you can get a faster notification.

    You can override these two methods to get a little quicker response to the change...

    - (void)lockWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler {
        [super lockWithCompletionHandler:^(NSError * _Nullable error) {
            if (completionHandler) completionHandler(error);
            if (!error) [self ensureReadOnlyConsistency];
        }];
    }
    
    - (void)unlockWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler {
        [super unlockWithCompletionHandler:^(NSError * _Nullable error) {
            if (completionHandler) completionHandler(error);
            if (!error) [self ensureReadOnlyConsistency];
        }];
    }
    

    I hope that's what you are looking for.