The Preference Pane Update Problem
The NSBundle
class in Cocoa is pretty nice. It’s an easy interface to access everything in a bundle, from Info.plist keys to localized strings, contained resources to loading executable code. It’s nice, but there is an issue with how it works that will cause problems whenever a user updates a preference pane by double-clicking a new version and have System Preference install it over the older one.
The symptoms
Try this experiment: take any non-Apple preference pane out there, install it, then open the content of the installed package and delete its main nib file (or all nib files if you want). The next time you open it, you’ll see a blank preference pane, perhaps with a few errors on the console or more drastic problems (as you should expect after breaking it).
Now say you want to reinstall the non-broken version of this preference pane. You double-click on it, System Preferences asks you to if you want to replace the current version with the new one, and click yes. Once it installed the new preference pane, System Preferences opens it and you’re presented with… exactly the same blank pane as before (!).
Surprised? If you quit then reopen System Preference and open your panel, the new version will now work flawlessly. “Good, looks like it was just a fluke!” But in reality, every time you redo those steps the same events happens. What’s the matter?
And the root of the problem is… NSBundle
!
Here is my understanding of what’s happening. System Preferences when it launches checks for every preference pane installed in its usual directories. It creates an instance of NSBundle
for each of them to get their localized name and icon. That’s a good practice, as the Bundle Programming Guide tells you:
However, if you plan to retrieve more than one resource file, it is always faster to use a bundle object. Bundle objects cache search information as they go, so subsequent searches are usually faster.
Upon upgrading a new preference pane, it replaces the old bundle with the new one. This creates a problem: all the cached information from the old bundle is still kept in the NSBundle
instance it created at launch time, making it out of sync with the actual content of the bundle.
The NSBundle
instance your preference pane receives through its initWithBundle:
method is thus contaminated by this outdated cached information. At this point, any use of this NSBundle
instance to access the bundle’s content may fail or return outdated data, or work fine depending on whatever was present in the old version of the preference pane and the nature of the requested content.
Initially, I thought this was limited to returning the old version of the infoDictionary
, because I was checking the version number in Magic Launch and displaying it in the panel, making that pretty obvious. But while working on bigger changes to the preference pane, I noticed the problem was more serious and applied to basically everything accessed through NSBundle
.
Attempt at a workaround
Basically, all we need is a way to clear NSBundle
’s cache. Unfortunately, there is no API to do that. Instead, let’s try create a new instance:
- (id)initWithBundle:(NSBundle *)bundle {
NSBundle *newBundle = [NSBundle bundleWithPath:[bundle bundlePath]];
[super initWithBundle:newBundle];
}
Looks right, but doesn’t work. NSBundle
keeps track of all its instances and bundleWithPath:
will return the same instance if one already leads to the same path. So we end up getting the same instance as before, with the same cached data and the same problems. Not good.
One could try to load every resource directly by path, bypassing NSBundle
cache, but this soon become tedious, and in my case I have code shared between the Magic Launch Agent application and the preference pane that depends on NSBundle
.
A similar but somewhat better solution would be to create a NSBundle
wrapper class that would implement its own logic for retrieving resources and everything, but could be passed to the rest of Cocoa as an authentic NSBundle
thanks to Objective-C dynamic binding. It’s rather hard work to duplicate everything in NSBundle
, and then you have to forward any call to unimplemented method to the wrapped NSBundle
for private methods or categories Apple might have in there to work.
That second solution would work, but I’m not feeling like writing all that code at the risk of introducing hard to notice bugs, and possibly forward-compatibility problems, just to fix the first landing after an upgrade done incorrectly by System Preferences.
The killer solution
The only acceptable solution I’ve found is this one: detect a version mismatch between the NSBundle instance and the actual code of the preference pane, and when there is a mismatch, just kill System Preferences and restart it. That’s rather drastic, I know, but it’s still better than having a half working, possibly buggy preference pane when first opening it after an upgrade.
What the user sees is the System Preference window momentarily disappearing, then reappearing and instantly switching to the newly upgraded preference pane.
It’s implemented like this:
- (id)initWithBundle:(NSBundle *)bundle {
static NSString *const expectedVersion = @"1.3";
NSString *bundleVersion = [[bundle infoDictionary]
objectForKey:(NSString *)kCFBundleVersionKey];
if (![bundleVersion isEqual:expectedVersion]) {
NSLog(@"Magic Launch version mismatch, restarting System Preferences...");
MLReopenPreferences(bundle);
exit(0);
}
}
(I’ll leave the implementation of MLReopenPreferences
above for a future post.)
What’s nice about this solution is that it’s forward compatible: if/when NSBundle
or System Preferences get fixed, there will no longer be a version mismatch, and no version mismatch means no more killing of System Preferences.
Comments
Did you create a bug on the Apple site? (https://developer.apple.com/bugreporter/)
@Alexandre: Yes. rdar://7890447
System Preferences provides
NSBundle
with out of date cached dataSummary:
After upgrading a preference pane by opening it with System Preferences, the
NSBundle
instance received by the preference pane’s main class ininitWithBundle:
still contains cached data from the previous version of the preference pane (because it’s a the same path), which has the wrong infoDictionary and prevents loading new resources not present in the previous version of the preference pane.Steps to Reproduce:
You need two versions of the same preference pane, the second one having a different Info.plist file or a resource file not present in the first version. Both versions of the preference pane have the same filename.
initWithBundle:
method of the preference pane’s main class should call the following methods on the preference pane’sNSBundle
instance and check the result:infoDictionary
pathForResource:ofType:
loadNibNamed:
Expected Results:
The three methods above should return values associated with the second version of the preference pane, with no trace of the first version left.
Actual Results:
The three methods above behave in part as if they were called for the previous version of the preference pane.
infoDictionary
returns the dictionary for the first version of the preference pane.pathForResource:ofType:
fails if the resource was not present in the first version of the preference pane.loadNibNamed:
fails if the nib file was not present in the first version of the preference pane.Notes:
Presumably, System Preferences should clear all the cached data in the preference pane’s
NSBundle
instance after installing a new version. There is a workaround of some sort, but it’s rather drastic and contorted: in each preference pane, detect this condition and force restart System Preferences when it happens. This is highly visible to the user and it breaks the navigation history in System Preferences, but it’s a lesser evil than having a buggy preference pane after upgrading.I recently started using _CFBundleFlushBundleCaches() to work around this problem. It’s a private function, but much better than the alternatives, in my opinion.
I’ve tested this technique on 10.5 and 10.6, both of which work as expected.
The header for this function can be found here and the source here.
Because it’s a private function, I weak-link the symbol and check for its existence at runtime:
Thanks for the article by the way - it helped me arrive at this solution.