The killer and the private eraser
In a previous post, I described a problem with the way System Preferences updates preference panes. The inelegant but working solution I found was to force quit System Preferences then reopen it immediately. I planned to explain how it works in this post. But in a recent comment, Dave Keck found a more elegant solution with no visible drawback and posted in the comments; the missing API does indeed exists, but only in private form. So I’ll explain how to use both to have a preference pane that upgrades correctly.
The killer solution
So how do we tell an application kill and relaunch itself? We first create a second process whose job is to relaunch the application, then we call exit(0)
from inside System Preferences:
BOOL ReopenPreferences(NSBundle *prefBundle) {
NSLog(@"Restarting System Preferences...");
const char *prefPath = [[prefBundle bundlePath] UTF8String];
const char *execPath = [[[prefBundle resourcePath]
stringByAppendingPathComponent:@"RestartPref"] UTF8String];
if (fork() == 0) {
// Execute RestartPref in a new process.
execl(execPath, execPath, prefPath, nil);
}
// Exit system preferences (quite drastic, I know!).
exit(0);
}
I could have use a more Cocoaesque way to do the above (using NSTask
) but I was more familiar with the Posix-style API, so I here I use fork
to create a child process and execl
to execute a secondary program in that process.
The program run in the secondary process is called “RestartPref” and is stored in the preference pane’s resources. The program is quite short:
int main(int argc, char **argv) {
NSAutoreleasePool *pool = [NSAutoreleasePool new];
// Just open given file
[[NSWorkspace sharedWorkspace]
openFile:[NSString stringWithUTF8String:argv[1]]];
[pool release];
exit(0);
}
This will just make the workspace open the file given in argument one; in this case, the file is our preference pane at the location it was installed. The user will see the System Preference window reappear showing the newly-installed preference pane.1
The private eraser
Returning to the base problem, all we really need to do is clear the cache in our NSBundle instances. Restarting System Preferences is just an extreme way to do that in the absence of a better mechanism.
It turns out that there is one such mechanism. Dave Keck found a function to do that. Unfortunately it’s a private API2, so it might be removed or changed in future versions of Mac OS X and we can’t really count on it being there. Still, for now we can use it with some care. Here is how Dave does it:
// First, we declare the function. Making it weak-linked
// ensures the preference pane won't crash if the function
// is removed from in a future version of Mac OS X.
extern void _CFBundleFlushBundleCaches(CFBundleRef bundle)
__attribute__((weak_import));
BOOL FlushBundleCache(NSBundle *prefBundle) {
// Before calling the function, we need to check if it exists
// since it was weak-linked.
if (_CFBundleFlushBundleCaches != NULL) {
NSLog(@"Flushing bundle cache with _CFBundleFlushBundleCaches");
CFBundleRef cfBundle =
CFBundleCreate(nil, (CFURLRef)[prefBundle bundleURL]);
_CFBundleFlushBundleCaches(cfBundle);
CFRelease(cfBundle);
return YES; // Success
}
return NO; // Not available
}
So that’s it. You just call _CFBundleFlushBundleCaches
on the equivalent CFBundle
whenever you detect a version mismatch and this’ll fix it.
Note that CFBundle
isn’t freely-bridged with Cocoa — you can’t just cast a NSBundle*
to a CFBundleRef
like you can with many other types — so we need to create a CFBundle
ourself to clear the cache. This works since we’re creating the CFBundle
using the same URL, so we’ll get the same instance of CFBundle
. And because the NSBundle
we have just uses CFBundle
under the hood, clearing the cache on the CFBundle
instance also clears it for Cocoa.
Still, since it’s a private API, you can’t really count on it being always present. Moreover you can’t really count on NSBundle
to be implemented that way either. For the case where this fails, you can always fall back to the killer solution.
The fable, finished
In the last post I’ve shown how to detect the problem and when to call ReopenPreferences
during a preference pane’s initialization. Let’s now add the cache cleaner as the first solution and use the restart only as a last resort:
- (id)initWithBundle:(NSBundle *)bundle {
static NSString *const expectedVersion = @"1.3";
NSString *bundleVersion = [[bundle infoDictionary]
objectForKey:(NSString *)kCFBundleVersionKey];
if (![bundleVersion isEqual:expectedVersion]) {
NSLog(@"Preference pane version mismatch.");
BOOL cacheCleared = NO;
if (FlushBundleCache()) {
// Check again to make sure it worked
NSString *bundleVersion = [[bundle infoDictionary]
objectForKey:(NSString *)kCFBundleVersionKey];
cacheCleared = [bundleVersion isEqual:expectedVersion];
}
if (!cacheCleared) {
ReopenPreferences(bundle);
exit(0);
}
}
}
And now the problem is fixed much more elegantly: as long as the private API exists and has the expected effect on our NSBundle
instance, we won’t to restart System Preference. Thank you Dave for the tip.
That said, it’d be much nicer if Apple themselves could fix their bug in System Preferences. (If you are someone at Apple who can fix this, please see rdar://7890447).
-
Note that this isn’t exactly what Magic Launch is doing. Magic Launch does a couple of things when first opening after an update and I feared that the System Preference window would take too much time to appear. Instead of the simple relauncher program above, I’m sending AppleEvents to System Preferences so that first the window appears and then Magic Launch opens. This is probably overkill for any normal preference pane so I’m only showing the simpler method here. ↩︎
-
The API is private, but since Core Foundation is open source you can easily find
_CFBundleFlushBundleCaches
’s declaration in the header file and its implementation in the source file. ↩︎