MacOS sandboxed application: access files without NSOpenPanel - macos

In a sandboxed NSDocument-based application, any compatible document can be accessed using the NSOpenPanel, no matter where the document is saved. Without NSOpenPanel, the application can only access files in the sandbox container.
As my application manages two types of subclassed NSdocument (Text as a reader/writer and Image as a reader only), I try to implement a separate "Open Recent" menu for images. I disabled the the ordinary behaviour for them as they are opened by the user, overriding the noteNewRecentDocumentURL: (NSURL *)url method of the NSDocumentController to return NO for image urls. So that only the text documents appear in the ordinary File -> Open Recent menu (and open normally when user select them). Images are listed in a custom menu.
The problem occurs with these image urls, because the application is sandboxed: the application cannot open directly any image file listed in the dedicated menu (any reading operation returns a -54 error. This behaviour can be checked using:
[[NSFileManager defaultManager] isReadableFileAtPath:[fileURL path]]
which always returns FALSE in this situation. There is only one exception to that: when I reopen, from the dedicated Open Recent menu, a file that has been previously opened with the NSOpenPanel in the same application session, then closed: in this case isReadableFileAtPath: returns TRUE and the file can be accessed. But when application quits and restarts, recent images files cannot be accessed this way.
I identified thre solutions to deal with this problem:
Moving the image file in the sandbox container as soon as it has been accessed "legally" by the user, through the NSOpenPanel. It works, of course, but prevent the user from deciding on his own the location of his files! In the same way, duplicating the file in the sandbox is not a solution.
Creating an alias to these files in the sandbox. As I couldn't find a way to do this, I couldn't test whether this is a solution or not.
Disable the application sandboxing. But this is the worse solution as there are many reasons to use sandboxing!
Is there a 4th solution, which would authorize a read-only access to any image file, wherever it is located, without disabling the sandbox?

You can't access any file no matter what.
Also I am not sure what your second solution means, that is probably the reason you couldn't follow it. You probably wanted to refer to 'security-scoped bookmarks' and not to 'aliases' and they work very well and that is the path that you should follow.

Well Ivan's suggestion was excellent. After a few readings (less than an hour), I could implement those security-scoped bookmark. For interested people, here are the main findings.
add the feature to your sandboxed application's entitlement file
set the com.apple.security.files.bookmarks.document-scope (or the com.apple.security.files.bookmarks.app-scope, or both) key to TRUE.
Modify your document opening method (which calls the NSOpenPanel) like this:
-(void) openMyDocument:(id)sender{
// ... do your stuff
[self.panel beginWithCompletionHandler:^(NSInteger result) {
if (result == NSModalResponseOK) {
NSURL* selectedURL = [[self.panel URLs] objectAtIndex:0];
NSData *bookmark = nil;
NSError *error = nil;
bookmark = [selectedURL bookmarkDataWithOptions:NSURLBookmarkCreationWithSecurityScope
includingResourceValuesForKeys:nil
relativeToURL:nil // Make it app-scoped
error:&error];
if (error) {
NSLog(#"Error while creating bookmark for URL (%#): %#", selectedURL, error);
}
NSString *access = [NSString stringWithFormat:#"%#%#", #"Access:", [selectedURL path]];
[[NSUserDefaults standardUserDefaults] setObject:bookmark forKey:access];
[[NSUserDefaults standardUserDefaults] synchronize];
// ... then open the document your way
}
}
}
Modify the method you created to read the file without using NSOpenPanel
- (void) openDocumentForScopedURL: (NSURL *) fileURL
NSString *accessKey = [NSString stringWithFormat:#"%#%#", #"Access:", [fileURL path]];
NSData *bookmarkData = [[NSUserDefaults standardUserDefaults] objectForKey:accessKey];
NSURL *bookmarkFileURL = nil;
if (bookmarkData == nil){
// no secured-scoped bookmark found, alert the user
return;
} else {
NSError *error = nil;
BOOL bookmarkDataIsStale;
bookmarkFileURL = [NSURL
URLByResolvingBookmarkData:bookmarkData
options:NSURLBookmarkResolutionWithSecurityScope
relativeToURL:nil
bookmarkDataIsStale:&bookmarkDataIsStale
error:&error];
[bookmarkFileURL startAccessingSecurityScopedResource];
}
// ... Then open your file, using bookmarkFileURL
// ... and do your stuff
// IMPORTANT. You must notify that stopped to access
[bookmarkFileURL stopAccessingSecurityScopedResource];
}

Related

Sandbox not extended when dragging URL from Finder

I'm implementing drag and drop from the Finder into a NSTableView.
It works fine, except that I can not (always) access the referenced file.
The (test) validation method is as follows:
-(NSDragOperation)tableView:(NSTableView *)tableView validateDrop:(id<NSDraggingInfo>)info proposedRow:(NSInteger)row proposedDropOperation:(NSTableViewDropOperation)dropOperation {
NSArray *newURLs = [info.draggingPasteboard readObjectsForClasses:[NSArray arrayWithObject:[NSURL class]]
options:#{NSPasteboardURLReadingFileURLsOnlyKey:#YES}];
for (NSURL *url in newURLs) {
if (![url startAccessingSecurityScopedResource])
NSLog(#"failed");
[url stopAccessingSecurityScopedResource];
}
}
This logs failed for the URLs received.
I receive the (file reference) URLs just fine from the Finder (their path is what I expect it to be).
The documentation states that dragging NSURL objects should work, so I'm a bit surprised that this doesn't work.
What's the correct way to drag URLs from the Finder to my app?

How to store preferences for a HTML-based QuickLook generator on OSX?

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:
First, create a small helper app, that will be bundled inside the
generator. This app will be responsible for writing the preference
file.
Make this app register a custom URL scheme and implement the handling associated with it.
Make your HTML-based QL open a specially-formatted URL, using that custom scheme, using Javascript.
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.
The flag indicating that this app is an agent (see picture). It prevents the app to appear in the doc when running.
The declaration of the custom URL scheme, with an 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:
<input id="save" type="button" value="Save">
Here is what the preferences look like in my QL window:
Et voilĂ !

How to retrieve iTunes preferences programmatically to find audio libraries?

I'm creating an application on the Mac in Cocoa that plays audio. I think the best place for my application to search for audio files on a user's computer is to get the directory path(s) from iTunes. How would I retrieve the directory paths(s) from iTunes? Would this be in some sort of preference setting?
Thanks.
Just for those who will encounter this problem in the future, I've documented the approach that I used below.
I decided not to use the Karelia framework because I don't need all the bells and whistles. I just need to locate the iTunes library and display a list of songs.
I went through the Karelia framework and it appears that they locate the iTunes library via the user defaults as shown below.
+ (NSArray*) parserInstancesForMediaType:(NSString*)inMediaType
{
NSMutableArray* parserInstances = [NSMutableArray array];
if ([self isInstalled])
{
CFArrayRef recentLibraries = CFPreferencesCopyAppValue((CFStringRef)#"iTunesRecentDatabases",(CFStringRef)#"com.apple.iApps");
NSArray* libraries = (NSArray*)recentLibraries;
for (NSString* library in libraries)
{
NSURL* url = [NSURL URLWithString:library];
NSString* path = [url path];
BOOL changed;
(void) [[NSFileManager imb_threadSafeManager] imb_fileExistsAtPath:&path wasChanged:&changed];
NSString *libraryPath = [path stringByDeletingLastPathComponent]; // folder containing .xml file
[IMBConfig registerLibraryPath:libraryPath];
IMBiTunesParser* parser = [[[self class] alloc] initWithMediaType:inMediaType];
parser.mediaSource = path;
parser.shouldDisplayLibraryName = libraries.count > 1;
[parserInstances addObject:parser];
[parser release];
}
if (recentLibraries) CFRelease(recentLibraries);
}
return parserInstances;
}
Using the above approach, you can locate all iTunes libraries with the following code.
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
NSDictionary *userPref = [userDefaults persistentDomainForName:#"com.apple.iApps"];
NSArray *recentDatabases = [userPref objectForKey:#"iTunesRecentDatabases"];
for (NSString *key in recentDatabases)
{
NSLog(#"%#", key);
}
Also if you inspect the user defaults iApps dictionary you can use these other keys to get info for other applications
iMovie
iPhotoAutoImportPath
iPhotoRecentDatabases
iTunesRecentDatabases
iTunesRecentDatabasePaths
Edit: As others have pointed out, the approach below doesn't address the case that the iTunes library may not be in a user's home folder. See David's answer for a better solution. Finding the user's music folder may still be somewhat useful for finding a standard (though not the only) location of non-iTunes audio files (e.g. as the default folder for an open panel).
Original answer:
You could locate the user's Music folder via
NSString *musicPath = [NSSearchPathForDirectoriesInDomains(NSMusicDirectory, NSUserDomainMask, YES) objectAtIndex:0];
This will normally have an iTunes subdirectory that contains the file "iTunes Music Library.xml" which contains most metadata of the user's iTunes library. Don't try to write to it though - iTunes only creates this file so that other apps can access it, but doesn't use it internally otherwise.
Use the Scripting Bridge to talk to iTunes. You can ask iTunes about the user's library and every track in it; file tracks will freely tell you where they are in the file-system.
Alternatively, embed Karelia Software's iMedia Browser framework, which will talk to iTunes for you.
You probably should also be able to open audio files that the user drops on your app or assigns to it in Finder's Get Info and opens. Consider making your application document-based; alternatively, implement the right application delegate method and declare the right Info.plist properties.

Finder-style UI for NSURL bookmarkData resolution with missing file?

I am using the new NSURL bookmark data API introduced in OS X 10.6 to store an "alias" to a file system resource. When I use
+[NSURL URLByResolvingBookmarkData:options:relativeToURL:bookmarkDataIsStale:error:]
to resolve the bookmark data, I get nil if the file no longer exists. Since I am not passing NSURLBookmarkResolutionWithoutUI in the bookmark resolution options, I expected to get a dialog, like that shown by the Finder when you open an alias file that no longer resolves (i.e. a dialog to cancel, fix the alias, etc...).
Is there a way to have NSURL automatically prompt to reconnect/resolve the broken bookmark data?
The bookmark data is created via:
NSError *err
NSData *bookmarkData = [myFileURL bookmarkDataWithOptions:NSURLBookmarkCreationSuitableForBookmarkFile
includingResourceValuesForKeys:nil
relativeToURL:nil
error:&err];
though I get the same outcome if I use 0 for the options instead of NSURLBookmarkCreationSuitableForBookmarkFile.
I attempt to resolve the same bookmarkData via:
BOOL stale;
NSError *err
NSURL *resolvedURL = [NSURL URLByResolvingBookmarkData:self.bookmarkData
options:0
relativeToURL:nil
bookmarkDataIsStale:&stale
error:&err];
Going through the dance of writing the bookmark data to a finder alias file and then resolving the URL by reading bookmark data from that file followed by the above method does not produce the desired UI result either (though opening the alias file in the Finder does produce the desired UI dialog).
No. The UI potentially involved in bookmark/alias resolution is that displayed if the target is on an unmounted fileserver which requires authentication to connect. The reconnection dialog is provided by the Finder in response to an unresolvable alias; if you want your application to have similar behavior, you will need to implement it yourself.

How do I handle multiple file drag/drop from Finder in Mac OS X 10.5?

I need to get the URLs of all files dragged/dropped into my application from Finder.
I have a Cocoa app running on 10.6 which does this by using the new 10.6 NSPasteboard APIs which handle multiple items on the pasteboard. I'm trying to backport this app to 10.5. How do I handle this on 10.5?
If I do something like below, I only get the first URL:
NSArray *pasteTypes = [NSArray arrayWithObjects: NSURLPboardType, nil];
NSString *bestType = [pboard availableTypeFromArray:pasteTypes];
if (bestType != nil) {
NSURL *url = [NSURL URLFromPasteboard:pboard];
}
Getting multiple filenames is easy: (While getting multiple URLs is not with 10.5)
Register your view for
NSFilenamesPboardType
In performDragOperation: do the following to get an array of file paths:
NSPasteboard* pboard = [sender draggingPasteboard];
NSArray* filenames = [pboard propertyListForType:NSFilenamesPboardType];
The IKImageKit programming topics outline a way to do this like so (paraphrased):
NSData *data = [pasteboard dataForType:NSFilenamesPboardType];
NSArray *filenames = [NSPropertyListSerialization
propertyListFromData:data
mutabilityOption:kCFPropertyListImmutable
format:nil
errorDescription:&errorDescription];
See here: Image Kit Programming Guide: Supporting Drag and Drop
The NSURLPboardType just handles one URL.
To get a list of files you need to create a NSArray from a NSFilenamesPboardType.
Apple's docs on drag and drop are pretty good, even if it's older stuff.
How do I handle [multiple items on a pasteboard] on 10.5?
Try the Pasteboard Manager.
The tricky part is that you're handling a drop, which means you're receiving an NSPasteboard already created for you, and there's no way to convert between NSPasteboard objects and PasteboardRefs. You'll have to ask the NSPasteboard for its name, then pass the same name to PasteboardCreate, and that may not work.
my two cents for swift 5.1 (drop in NSView... to be customized)
see at:
Swift: Opening a file by drag-and-drop in window

Resources