I used to do it with a custom PrefPane that has to be installed separately, but that's not satisfactory.
I have a HTML-based QuickLook generator, that create thumbnails and previews of some inhomogeneous content (files that have a long ASCII header, and a various number of binary extensions each of them with some header).
Inside the QL method OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options)
, I tried to use [NSUserDefaults standardUserDefaults]
, but it has no effect, and writes no preference file, probably because we are inside a bundle, and not an app.
Any idea how to achieve this? I know that some excellent QL generators do it, such as BetterZip QL. I tried to reverse engineer the BetterZip QL, but with no success.
I directly contacted the author of the BetterZip QL, and together, we came up with a solution. Here it is.
In short:
Ok, now in details.
First create a small helper app target inside your Xcode workspace/project. My QL generator was named QLFits, I chose QLFitsConfig.
By default, there is a MainMenu.xib associated that app. Keep it. It is used by the Info.plist, and it can be useful for debugging. As a matter of fact, to debug the custom URL scheme, you can add a NSWindow to that xib, and put labels which could be used to display debug messages. I found no real other way to log or display debug messages when debugging this problem.
But at the end, you have a small windowless app. There are two configuration things that this app must have.
Editor
role. See also the picture for an example (here qlfitsconfig
)Next, the implementation of the app needs to register the URL scheme to tell the system there is an app that is capable of opening it. Below the implementation of my "appDidFinishLaunching" method in the AppDelegate of the app.
There is three parts: The registration of the handler of the custom URL scheme. The instantiation of a NSUserDefaults
object with a suite name that is shared with the QL generator. And finally, the registration of the default values of the preferences (using a .plist file bundled with the app).
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
NSAppleEventManager *appleEventManager = [NSAppleEventManager sharedAppleEventManager];
[appleEventManager setEventHandler:self andSelector:@selector(handleAppleEvent:withReplyEvent:) forEventClass:kInternetEventClass andEventID:kAEGetURL];
[[NSUserDefaults standardUserDefaults] addSuiteNamed:suiteName];
NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:suiteName];
NSString *optionsPath = [[NSBundle mainBundle] pathForResource:@"defaultOptions" ofType:@"plist"];
NSDictionary *defaultOptions = [NSDictionary dictionaryWithContentsOfFile:optionsPath];
[defaults registerDefaults:defaultOptions];
}
The suiteName
variable is a static NSString with a reverse-DNS format: static NSString *suiteName = @"com.onekiloparsec.qlfitsconfig.user-defaults-suite";
Then, the app needs to act upon the triggering of the event. Hence, one must do something with the event, and use that event to store the preference. Here is the implementation. Note that the signature of the method must be precisely that one, not because we declare it so above, but because that's the only one recognised by the system.
- (void)handleAppleEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent
{
NSString *URLString = [[event paramDescriptorForKeyword:keyDirectObject] stringValue];
if (URLString) {
NSURL *URL = [NSURL URLWithString:URLString];
if (URL) {
NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:suiteName];
for (NSString *component in [URL pathComponents]) {
if ([component containsString:@"="]) {
NSArray *keyValue = [component componentsSeparatedByString:@"="];
[defaults setObject:keyValue.lastObject forKey:keyValue.firstObject];
}
}
[defaults synchronize];
}
}
}
The basic idea is that we will provide preferences through URL parameters as key-value pairs. Hence, we transformed here that URL string into pair of preferences, that are stored as strings.
That's all for the app. To test and debug it, you need to build and run it (check with the /Utilities/Activity Monitor.app that it is running, for instance). You can type the following commands into a Terminal to see what happens:
$ open qlfitsconfig://save/option1=value1/option2=value2
And if you have kept the window with labels mentioned above, you can use them to display/debug what event your app receives.
Now, back to the QL generator. Include the config app as a "Target Dependency" in the "Build Phases" of the generator. Moreover, add a new "Copy Files" Build Phase (after the Copy Bundle Resources build phase) to copy that helper app inside the QL bundle (see picture).
Now, in the code, more precisely, inside the method preview method: OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options)
.
At the beginning of it, I make sure the config help app is actually registered with the Launch Services of the system. To find it, one must use the bundle identifier of the QL generator. Note especially how the URL of the app is constructed, based on where it is copied in the Build phase (the Helpers
directory).
NSBundle *bundle = [NSBundle bundleWithIdentifier:@"com.onekiloparsec.QLFits3"];
NSURL *urlConfig = [NSURL fileURLWithPath:[[bundle bundlePath] stringByAppendingPathComponent:@"Contents/Helpers/QLFitsConfig.app"]];
LSRegisterURL((__bridge CFURLRef) urlConfig, true);
The last line is using legacy APIs, but I couldn't make the new ones working. This is a weakness, and one should probably find a better way at some point.
Now, if some preferences were already saved, one can access them with an instance of NSUserDefaults
assuming we initialise it with the same suite name as defined in the helper app. Example:
static NSString *suiteName = @"com.onekiloparsec.qlfitsconfig.user-defaults-suite";
NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:suiteName];
BOOL alwaysShowHeaders = YES;
if ([defaults stringForKey:@"alwaysShowHeaders"]) {
alwaysShowHeaders = [[defaults stringForKey:@"alwaysShowHeaders"] isEqualToString:@"1"];
}
That's it for the Obj-C code.
The last part is the Javascript code. In my QL generator (whose code can be checked on GitHub), I use a template.html
file containing all the html and JS code. You can organise yourself differently here.
I first intended to change the QL preferences when checkboxes were toggled. But it appeared to not work (no events are triggered). The only way I made it work is that once my checkboxes where set, the user is requested to "save" the preferences using a button. And I save the preferences upon the clicking of that button. Here is the JS code inside my template.html
<script>
function saveConfig (a) {
a.href = "qlfitsconfig://save";
a.href += "/alwaysShowHeaders=" + (document.getElementById("alwaysShowHeadersInput").checked ? "1" : "0");
a.href += "/showSummaryInThumbnails=" + (document.getElementById("showSummaryInThumbnailsInput").checked ? "1" : "0");
return true;
}
</script>
alwaysShowHeadersInput
and showSummaryInThumbnailsInput
are the 'id' of my checkboxes in the HTML code. And the save button is triggering the saveConfig
function.
And the button must be declared inside an a
tag:
<a href="#" onClick="saveConfig(this);return true;" style="float:right;"><input id="save" type="button" value="Save"></a>
Here is what the preferences look like in my QL window:
Et voilà!