Living in a Sandboxed World

No matter what your view on Apple’s new sandboxing requirement for the Mac App Store, if you want to keep updating your MAS apps, you’re going to need to sandbox them. I was able to sandbox Seasonality Core pretty easily. I don’t access any files outside of Application Support and the prefs file. My entitlements just require outgoing network connections, which was pretty easy to enable in the target settings.
However, I distribute two versions of Seasonality Core. One is the Mac App Store application, and the other is a version for my pre-Mac App Store customers. The question arose: should I sandbox the non-Mac App Store application? I wanted the answer to this question to be yes, but unfortunately the serial number licensing framework I am using kept me from doing this. So I was forced to sandbox the Mac App Store version, but keep the non-Mac App Store version outside the sandbox. Crap.
You might be wondering what the big deal is here. Can’t my Mac App Store customers just use one app, and pre-Mac App Store customers use the other one? Well, yes, but there are a few situations where some customers might use both versions of the app.
If someone uses both the Mac App Store version and the non-Mac App Store version, things go south quickly. The first time the sandboxed Mac App Store version is run, all of Seasonality Core’s data files will be migrated into the sandbox. That means the next time the non-Mac App Store version is opened, it won’t be able to see any of the past data Seasonality Core has collected. That’s not good.
So how did I get around this? After taking a quick poll on Twitter, it sounded like the best option for me would be to have the non-Mac App Store version look reach inside my app’s sandbox if it existed. To do this, I just had to build some extra code into the method that returns my Application Support path. Here’s the new implementation:

+ (NSString *) seasonalityCoreSupportPath {
NSFileManager *fm = [NSFileManager defaultManager];

#ifndef MAC_APP_STORE
// Check if ~/Library/Containers/BundleID/Data/Library/Application Support/Seasonality Core exists.
NSString *sandboxedAppSupportPath = [NSString pathWithComponents:
[NSArray arrayWithObjects:@"~",
@"Library",
@"Containers",
[[NSBundle mainBundle] bundleIdentifier],
@"Data",
@"Library",
@"Application Support",
@"Seasonality Core",
nil]
];
sandboxedAppSupportPath = [sandboxedAppSupportPath stringByExpandingTildeInPath];

BOOL isDir;
if ([fm fileExistsAtPath:sandboxedAppSupportPath isDirectory:&isDir]) {
// We found a sandboxed Application Support directory, return it.
if (isDir) return sandboxedAppSupportPath;
}
#endif

NSArray *appSupportURLs = [fm URLsForDirectory:NSApplicationSupportDirectory
inDomains:NSUserDomainMask];
NSString *appSupportDirectory = nil;

if (appSupportURLs.count > 0) {
NSURL *firstPath = [appSupportURLs objectAtIndex:0];
appSupportDirectory = [firstPath path];
}

return [appSupportDirectory stringByAppendingPathComponent:@"Seasonality Core"];
}

The new code only runs if the MAC_APP_STORE isn’t defined (these are project definitions I have set elsewhere for the different builds). We check to see if there is a sandbox for the app, and if so it will return the sandboxed directory. Otherwise it returns the standard Application Support directory.
This is a pretty complete solution, except that I wanted to make sure the user’s preferences were saved between the two app versions as well. NSUserDefaults won’t know to check for the existence of a sandbox. Daniel Jalkut gracefully offered this solution, which I have since adapted into my own code as follows:

+ (BOOL) gsImportNewerPreferencesForBundle:(NSString *)bundleName fromSandboxContainerID:(NSString *)containerID {
BOOL didMigrate = NO;
NSArray *libraryFolders = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES);
if (libraryFolders.count) {
// Get a path to our app's preference file.
NSString *prefsFile = [NSString pathWithComponents:[NSArray arrayWithObjects:
[libraryFolders objectAtIndex:0],
@"Preferences",
bundleName,
nil
]];
prefsFile = [prefsFile stringByAppendingPathExtension:@"plist"];

// Get a path to the same preference file in the given sandbox container.
NSString *containerPrefsFile = [NSString pathWithComponents:[NSArray arrayWithObjects:
[libraryFolders objectAtIndex:0],
@"Containers",
containerID,
@"Data",
@"Library",
@"Preferences",
bundleName,
nil
]];
containerPrefsFile = [containerPrefsFile stringByAppendingPathExtension:@"plist"];
NSFileManager* fm = [NSFileManager defaultManager];
if ([fm fileExistsAtPath:containerPrefsFile]) {
NSDate *prefsModDate = [[fm attributesOfItemAtPath:prefsFile error:nil] objectForKey:NSFileModificationDate];
NSDate *containerModDate = [[fm attributesOfItemAtPath:containerPrefsFile error:nil] objectForKey:NSFileModificationDate];
if ((prefsModDate == nil) || ([prefsModDate compare:containerModDate] == NSOrderedAscending)) {
// Copy the file.
[fm copyItemAtPath:containerPrefsFile toPath:prefsFile error:nil];
// Reset so the next call to [NSUserDefaults standardUserDefaults]
// recreates an object to the new prefs file.
[NSUserDefaults resetStandardUserDefaults];

NSLog(@"Found newer preferences in %@ - importing", containerPrefsFile);
didMigrate = YES;
}
}
}

return didMigrate;
}

I call the above preferences migration code directly from main(), so it executes before the any part of the main app might hit NSUserDefaults. Works pretty well thus far.