Adorn-big-developer

Yet Another Take on Sharing User Defaults in a Sandbox

The next release of Balthisar Tidy will include two awesome features: Services support and (for Yosemite and higher) Action Extension support. Given that Extensions require the containing application to be sandboxed, it finally came time to figure out how to use Apple's “Application Groups” in order to share user defaults.

Although there are many articles out there that describe how this might be done (and they were indeed a great help), they all unsatisfactorily didn't address the user interface aspect, specifically how to deal with NSUserDefaultsController that’s used in Interface Builder so commonly.

tl;dr:

In your main application create an instance of NSUserDefaults using the shared suite and then simply mirror your standardUserDefaults into it using the notification system.

Problem Description

Sandboxing intentionally makes it difficult for applications to access resources outside of their own sandbox, and this includes the sharing of user defaults.

Balthisar Tidy implements the Tidy Service as a faceless app in the main bundle rather than forcing the entire application to open each time the Service is invoked, and its Action is, of course, packaged as a plugin. Neither the Service nor the Action is entirely useful without being able to access Balthisar Tidy’s user defaults, which control how tidy will process and format the HTML.

Application Groups to the Rescue

Fortunately Apple has allowed the user of Application Groups as a mechanism for sharing user defaults between applications that are signed by the same development team, and (importantly!) prefixed with your Developer Team ID, for example, 9PN2JXXG7Y.com.balthisar.Balthisar-Tidy.prefs.

Both plugins then have access to the shared user defaults by accessing them from the defaults instance created like this:

NSUserDefaults *localDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"9PN2JXXG7Y.com.balthisar.Balthisar-Tidy.prefs"];

The Hangup

Balthisar Tidy (like many applications and frameworks) has come to rely on the always-available [NSUserDefaults standardUserDefaults] instance, and a lot of code — instead of abstracting this away as we should perhaps have done — sprinkles this in liberally. However just because an application is the main application doesn’t excuse it from the need to write its preferences into the shared suite if you want to expose those settings to the other applications in your suite.

Refactoring all of these direct calls into, say, a shared instance in an app is trivial, and it would allow your application to write into the Application Suite defaults instead of standardUserDefaults, but it ignores two critical things:

Frameworks and Libraries
Just as you are guilty of unfettered direct use of[NSUserDefaults standardUserDefaults], the folks who wrote some of the libraries you use did this too. I tend not to want to modify third party libraries without an accepted pull request.
Apple’s own code
Apple’s own code, such as NSUserDefaultsController also hard-wires to [NSUserDefaults standardUserDefaults] without any convenient mechanism to override this. Subclassing could have been an option, but it seems unnecessary and complicates the NIBs for no good reason.

The Solution

Refactor this for your own circumstances, but it’s as easy as:

NSUserDefaults *mirroredDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"9PN2JXXG7Y.com.balthisar.Balthisar-Tidy.prefs"];

[[NSNotificationCenter defaultCenter] addObserver:self
                                         selector:@selector(handleUserDefaultsChanged:)
                                             name:NSUserDefaultsDidChangeNotification
                                           object:[NSUserDefaults standardUserDefaults]];

- (void)handleUserDefaultsChanged:(NSNotification*)note
{
   NSDictionary *localDict = [[[NSUserDefaults standardUserDefaults] dictionaryRepresentation] objectForKey:JSDKeyTidyTidyOptionsKey];
   [mirroredDefaults setObject:localDict forKey:JSDKeyTidyTidyOptionsKey];
   [mirroredDefaults synchronize];
}

This example only copies a nested group of defaults in the JSDKeyTidyTidyOptionsKey (because Balthisar Tidy doesn’t have a need to share all defaults), but it’s trivial to rework this to include any or all defaults that you want to copy.

This example is also one-way only because the plugins don’t modify user defaults. If your own application requires two way exchange, apt use of synchronize and some simple logic should make it elementary to do so.

A Word of Warning

Since Mac OS X 10.9 (Mavericks) user defaults are cached by the operating system, even when synchronize is used. The user defaults system works perfectly fine with this caching; only direct access to the preferences’ plist files is risky.

This means that we have to change our lifelong habit of modifying plist files directly to change the settings in our favorite apps and learn to use the defaults command in the terminal instead. In fact many times it’s not sufficient to to delete an application’s preferences file in order to get a “clean start”; the caching system may very well replace that file with the cached information at next launch! Using defaults.delete in terminal will always work. Now is the time to get used to it.

This is also important if you use any libraries or frameworks that depend on direct file access to share user defaults! There is no guarantee that the file representation is correct. During my research I encountered at least one fairly high profile library that promises to synchronize shared preferences in a sandbox environment. It counts on file access.

Do yourself a favor and stick to using Cocoa’s built-in methods instead, unless you have to support Mac OS X 10.8 (which doesn’t support initWithSuiteName).

comments powered by Disqus