//============================================================================== // This file is part of Master Password. // Copyright (c) 2011-2017, Maarten Billemont. // // Master Password is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Master Password is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You can find a copy of the GNU General Public License in the // LICENSE file. Alternatively, see . //============================================================================== #import "MPMacAppDelegate.h" #import "MPAppDelegate_Key.h" #import "MPAppDelegate_Store.h" #import #import #define LOGIN_HELPER_BUNDLE_ID @"com.lyndir.lhunath.MasterPassword.Mac.LoginHelper" @implementation MPMacAppDelegate #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wfour-char-constants" static EventHotKeyID MPShowHotKey = { .signature = 'show', .id = 1 }; static EventHotKeyID MPLockHotKey = { .signature = 'lock', .id = 1 }; #pragma clang diagnostic pop + (void)initialize { static dispatch_once_t once = 0; dispatch_once( &once, ^{ [MPMacConfig get]; #ifdef DEBUG [PearlLogger get].printLevel = PearlLogLevelDebug; //Trace; #endif } ); } static OSStatus MPHotKeyHander(EventHandlerCallRef nextHandler, EventRef theEvent, void *userData) { // Extract the hotkey ID. EventHotKeyID hotKeyID; GetEventParameter( theEvent, kEventParamDirectObject, typeEventHotKeyID, NULL, sizeof( hotKeyID ), NULL, &hotKeyID ); // Check which hotkey this was. if (hotKeyID.signature == MPShowHotKey.signature && hotKeyID.id == MPShowHotKey.id) { [((__bridge MPMacAppDelegate *)userData) showPasswordWindow:nil]; return noErr; } if (hotKeyID.signature == MPLockHotKey.signature && hotKeyID.id == MPLockHotKey.id) { [((__bridge MPMacAppDelegate *)userData) lock:nil]; return noErr; } return eventNotHandledErr; } #pragma mark - Life - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { #ifdef CRASHLYTICS NSString *crashlyticsAPIKey = [self crashlyticsAPIKey]; if ([crashlyticsAPIKey length]) { inf(@"Initializing Crashlytics"); #if DEBUG [Crashlytics sharedInstance].debugMode = YES; #endif [[Crashlytics sharedInstance] setUserIdentifier:[PearlKeyChain deviceIdentifier]]; [[Crashlytics sharedInstance] setObjectValue:[PearlKeyChain deviceIdentifier] forKey:@"deviceIdentifier"]; [[Crashlytics sharedInstance] setUserName:@"Anonymous"]; [Crashlytics startWithAPIKey:crashlyticsAPIKey]; [[PearlLogger get] registerListener:^BOOL(PearlLogMessage *message) { PearlLogLevel level = PearlLogLevelInfo; if ([[MPConfig get].sendInfo boolValue]) level = PearlLogLevelDebug; if (message.level >= level) CLSLog( @"%@", [message messageDescription] ); return YES; }]; CLSLog( @"Crashlytics (%@) initialized for: %@ v%@.", // [Crashlytics sharedInstance].version, [PearlInfoPlist get].CFBundleName, [PearlInfoPlist get].CFBundleVersion ); } #endif // Setup delegates and listeners. [MPConfig get].delegate = self; __weak id weakSelf = self; [self addObserverBlock:^(NSString *keyPath, id object, NSDictionary *change, void *context) { dispatch_async( dispatch_get_main_queue(), ^{ [weakSelf updateMenuItems]; } ); } forKeyPath:@"key" options:0 context:nil]; [self addObserverBlock:^(NSString *keyPath, id object, NSDictionary *change, void *context) { dispatch_async( dispatch_get_main_queue(), ^{ [weakSelf updateMenuItems]; } ); } forKeyPath:@"activeUser" options:0 context:nil]; // Status item. self.statusView = [[NSStatusBar systemStatusBar] statusItemWithLength:NSSquareStatusItemLength]; self.statusView.image = [NSImage imageNamed:@"menu-icon"]; self.statusView.image.template = YES; self.statusView.menu = self.statusMenu; self.statusView.target = self; self.statusView.action = @selector( showMenu ); PearlAddNotificationObserver( NSPersistentStoreCoordinatorStoresWillChangeNotification, self.storeCoordinator, nil, ^(id self, NSNotification *note) { PearlMainQueue( ^{ [self updateUsers]; } ); } ); PearlAddNotificationObserver( NSPersistentStoreCoordinatorStoresDidChangeNotification, self.storeCoordinator, nil, ^(id self, NSNotification *note) { PearlMainQueue( ^{ [self updateUsers]; } ); } ); PearlAddNotificationObserver( MPCheckConfigNotification, nil, nil, ^(MPMacAppDelegate *self, NSNotification *note) { PearlMainQueue( ^{ NSString *key = note.object; if (!key || [key isEqualToString:NSStringFromSelector( @selector( hidePasswords ) )]) self.hidePasswordsItem.state = [[MPConfig get].hidePasswords boolValue]? NSOnState: NSOffState; if (!key || [key isEqualToString:NSStringFromSelector( @selector( rememberLogin ) )]) self.rememberPasswordItem.state = [[MPConfig get].rememberLogin boolValue]? NSOnState: NSOffState; } ); } ); [self updateUsers]; // Global hotkey. EventHotKeyRef hotKeyRef; EventTypeSpec hotKeyEvents[1] = { { .eventClass = kEventClassKeyboard, .eventKind = kEventHotKeyPressed } }; OSStatus status = InstallApplicationEventHandler( NewEventHandlerUPP( MPHotKeyHander ), GetEventTypeCount( hotKeyEvents ), hotKeyEvents, (__bridge void *)self, NULL ); if (status != noErr) err( @"Error installing application event handler: %i", (int)status ); status = RegisterEventHotKey( 35 /* p */, controlKey + cmdKey, MPShowHotKey, GetApplicationEventTarget(), 0, &hotKeyRef ); if (status != noErr) err( @"Error registering 'show' hotkey: %i", (int)status ); status = RegisterEventHotKey( 35 /* p */, controlKey + optionKey + cmdKey, MPLockHotKey, GetApplicationEventTarget(), 0, &hotKeyRef ); if (status != noErr) err( @"Error registering 'lock' hotkey: %i", (int)status ); // Initial display. if ([[MPMacConfig get].firstRun boolValue]) { [(self.initialWindowController = [[MPInitialWindowController alloc] initWithWindowNibName:@"MPInitialWindow"]) .window makeKeyAndOrderFront:self]; [NSApp activateIgnoringOtherApps:YES]; } } - (void)applicationWillResignActive:(NSNotification *)notification { if (![[MPConfig get].rememberLogin boolValue]) [self lock:nil]; } - (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender { // Save changes in the application's managed object context before the application terminates. NSManagedObjectContext *mainContext = [MPMacAppDelegate managedObjectContextForMainThreadIfReady]; if (!mainContext) return NSTerminateNow; if (![mainContext commitEditing]) return NSTerminateCancel; if (![mainContext hasChanges]) return NSTerminateNow; [mainContext saveToStore]; return NSTerminateNow; } #pragma mark - State - (void)setActiveUser:(MPUserEntity *)activeUser { [super setActiveUser:activeUser]; if (activeUser) [MPMacConfig get].usedUserName = activeUser.name; PearlMainQueue( ^{ [self updateUsers]; } ); } - (void)setLoginItemEnabled:(BOOL)enabled { BOOL loginItemEnabled = [self loginItemEnabled]; if (loginItemEnabled != enabled) { if (SMLoginItemSetEnabled( (__bridge CFStringRef)LOGIN_HELPER_BUNDLE_ID, (Boolean)enabled ) == true) loginItemEnabled = enabled; else wrn( @"Failed to set login item." ); } self.openAtLoginItem.state = loginItemEnabled? NSOnState: NSOffState; self.initialWindowController.openAtLoginButton.state = loginItemEnabled? NSOnState: NSOffState; } - (BOOL)loginItemEnabled { // The easy and sane method (SMJobCopyDictionary) can pose problems when the app is sandboxed. -_- NSArray *jobs = (__bridge_transfer NSArray *)SMCopyAllJobDictionaries( kSMDomainUserLaunchd ); for (NSDictionary *job in jobs) if ([LOGIN_HELPER_BUNDLE_ID isEqualToString:job[@"Label"]]) return [job[@"OnDemand"] boolValue]; return NO; } - (BOOL)isFeatureUnlocked:(NSString *)productIdentifier { // All features are unlocked for mac versions. return YES; } #pragma mark - Actions - (void)selectUser:(NSMenuItem *)item { [self signOutAnimated:NO]; NSManagedObjectContext *mainContext = [MPMacAppDelegate managedObjectContextForMainThreadIfReady]; self.activeUser = [MPUserEntity existingObjectWithID:[item representedObject] inContext:mainContext]; } - (IBAction)exportSitesSecure:(id)sender { [self exportSitesAndRevealPasswords:NO]; } - (IBAction)exportSitesReveal:(id)sender { [self exportSitesAndRevealPasswords:YES]; } - (IBAction)importSites:(id)sender { NSOpenPanel *openPanel = [NSOpenPanel openPanel]; openPanel.allowsMultipleSelection = NO; openPanel.canChooseDirectories = NO; openPanel.title = @"Master Password"; openPanel.message = @"Locate the Master Password export file to import."; openPanel.prompt = @"Import"; openPanel.directoryURL = [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject; openPanel.allowedFileTypes = @[ @"mpsites" ]; [NSApp activateIgnoringOtherApps:YES]; if ([openPanel runModal] == NSFileHandlingPanelCancelButton) return; NSURL *url = openPanel.URL; [openPanel close]; [[[NSURLSession sharedSession] dataTaskWithURL:url completionHandler: ^(NSData *importedSitesData, NSURLResponse *response, NSError *urlError) { if (urlError) [[NSAlert alertWithError:MPError( urlError, @"While reading imported sites from %@.", url )] runModal]; if (!importedSitesData) return; NSString *importedSitesString = [[NSString alloc] initWithData:importedSitesData encoding:NSUTF8StringEncoding]; [self importSites:importedSitesString askImportPassword:^NSString *(NSString *userName) { __block NSString *masterPassword = nil; PearlMainQueueWait( ^{ NSAlert *alert = [NSAlert new]; [alert addButtonWithTitle:@"Unlock"]; [alert addButtonWithTitle:@"Cancel"]; alert.messageText = strf( @"Importing Sites For\n%@", userName ); alert.informativeText = @"Enter the master password used to create this export file."; alert.accessoryView = [[NSSecureTextField alloc] initWithFrame:NSMakeRect( 0, 0, 200, 22 )]; [alert layout]; if ([alert runModal] == NSAlertFirstButtonReturn) masterPassword = ((NSTextField *)alert.accessoryView).stringValue; } ); return masterPassword; } askUserPassword:^NSString *(NSString *userName) { __block NSString *masterPassword = nil; PearlMainQueueWait( ^{ NSAlert *alert = [NSAlert new]; [alert addButtonWithTitle:@"Import"]; [alert addButtonWithTitle:@"Cancel"]; alert.messageText = strf( @"Master Password For\n%@", userName ); alert.informativeText = @"Enter the current master password for this user."; alert.accessoryView = [[NSSecureTextField alloc] initWithFrame:NSMakeRect( 0, 0, 200, 22 )]; [alert layout]; if ([alert runModal] == NSAlertFirstButtonReturn) masterPassword = ((NSTextField *)alert.accessoryView).stringValue; } ); return masterPassword; } result:^(NSError *error) { [self updateUsers]; if (error && !(error.domain == NSCocoaErrorDomain && error.code == NSUserCancelledError)) [[NSAlert alertWithError:error] runModal]; }]; }] resume]; } - (IBAction)togglePreference:(id)sender { if (sender == self.hidePasswordsItem) [MPConfig get].hidePasswords = @(self.hidePasswordsItem.state != NSOnState); if (sender == self.rememberPasswordItem) [MPConfig get].rememberLogin = @(self.rememberPasswordItem.state != NSOnState); if (sender == self.openAtLoginItem) [self setLoginItemEnabled:self.openAtLoginItem.state != NSOnState]; if (sender == self.showFullScreenItem) { [MPMacConfig get].fullScreen = @(self.showFullScreenItem.state != NSOnState); [NSApp updateWindows]; } if (sender == self.savePasswordItem) { [MPMacAppDelegate managedObjectContextPerformBlockAndWait:^(NSManagedObjectContext *context) { MPUserEntity *activeUser = [[MPMacAppDelegate get] activeUserInContext:context]; if ((activeUser.saveKey = !activeUser.saveKey)) [[MPMacAppDelegate get] storeSavedKeyFor:activeUser]; else [[MPMacAppDelegate get] forgetSavedKeyFor:activeUser]; [context saveToStore]; }]; } [MPMacConfig flush]; [self updateMenuItems]; } - (IBAction)newUser:(NSMenuItem *)sender { NSAlert *alert = [NSAlert new]; [alert setMessageText:@"New User"]; [alert setInformativeText:@"To begin, enter your full name.\n\n" @"IMPORTANT: Enter your name correctly, including the right capitalization, " @"as you would on an official document."]; [alert addButtonWithTitle:@"Create User"]; [alert addButtonWithTitle:@"Cancel"]; NSTextField *nameField = [[NSTextField alloc] initWithFrame:NSMakeRect( 0, 0, 200, 22 )]; [alert setAccessoryView:nameField]; [alert layout]; [nameField becomeFirstResponder]; if ([alert runModal] != NSAlertFirstButtonReturn) return; NSString *name = [(NSSecureTextField *)alert.accessoryView stringValue]; [MPMacAppDelegate managedObjectContextPerformBlock:^(NSManagedObjectContext *context) { MPUserEntity *newUser = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass( [MPUserEntity class] ) inManagedObjectContext:context]; newUser.name = name; [context saveToStore]; [self setActiveUser:newUser]; PearlMainQueue( ^{ [self showPasswordWindow:nil]; } ); }]; } - (IBAction)deleteUser:(NSMenuItem *)sender { NSAlert *alert = [NSAlert new]; [alert setMessageText:@"Delete User"]; [alert setInformativeText:strf( @"This will delete %@ and all their sites.", self.activeUserForMainThread.name )]; [alert addButtonWithTitle:@"Delete"]; [alert addButtonWithTitle:@"Cancel"]; if ([alert runModal] != NSAlertFirstButtonReturn) return; [MPMacAppDelegate managedObjectContextPerformBlock:^(NSManagedObjectContext *context) { [context deleteObject:[self activeUserInContext:context]]; [self setActiveUser:nil]; [context saveToStore]; [[NSOperationQueue mainQueue] addOperationWithBlock:^{ [self updateUsers]; [self showPasswordWindow:nil]; }]; }]; } - (IBAction)lock:(id)sender { [self signOutAnimated:YES]; } - (IBAction)terminate:(id)sender { [self.sitesWindowController close]; self.sitesWindowController = nil; [NSApp terminate:nil]; } - (IBAction)showPopup:(id)sender { [self.statusView popUpStatusItemMenu:self.statusView.menu]; } - (IBAction)showPasswordWindow:(id)sender { prof_new( @"showPasswordWindow" ); [NSApp activateIgnoringOtherApps:YES]; prof_rewind( @"activateIgnoringOtherApps" ); // If no user, can't activate. if (![self activeUserForMainThread]) { NSAlert *alert = [NSAlert new]; alert.messageText = @"No User Selected"; alert.informativeText = @"Begin by selecting or creating your user from the status menu (●●●|) next to the clock."; [alert runModal]; [self showPopup:nil]; prof_finish( @"activeUserForMainThread" ); return; } prof_rewind( @"activeUserForMainThread" ); // Don't show window if we weren't already running (ie. if we haven't been activated before). if (!self.sitesWindowController) self.sitesWindowController = [[MPSitesWindowController alloc] initWithWindowNibName:@"MPSitesWindowController"]; prof_rewind( @"initWithWindow" ); [self.sitesWindowController showWindow:self]; prof_finish( @"showWindow" ); } #pragma mark - Private - (void)exportSitesAndRevealPasswords:(BOOL)revealPasswords { MPUserEntity *mainActiveUser = [self activeUserForMainThread]; if (!mainActiveUser) { NSAlert *alert = [NSAlert new]; alert.messageText = @"No User Selected"; alert.informativeText = @"To export your sites, first select the user whose sites to export."; [alert runModal]; [self showPopup:nil]; return; } if (!self.key) { NSAlert *alert = [NSAlert new]; alert.messageText = @"User Locked"; alert.informativeText = @"To export your sites, first unlock your user by opening Master Password."; [alert runModal]; [self showPopup:nil]; return; } NSDateFormatter *exportDateFormatter = [NSDateFormatter new]; [exportDateFormatter setDateFormat:@"yyyy'-'MM'-'dd"]; NSSavePanel *savePanel = [NSSavePanel savePanel]; savePanel.title = @"Master Password"; savePanel.message = @"Pick a location for the export Master Password's sites."; if (revealPasswords) savePanel.message = strf( @"%@\nWARNING: Your passwords will be visible. Make sure to always keep the file in a secure location.", savePanel.message ); savePanel.prompt = @"Export"; savePanel.directoryURL = [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject; savePanel.nameFieldStringValue = strf( @"%@ (%@).mpsites", mainActiveUser.name, [exportDateFormatter stringFromDate:[NSDate date]] ); savePanel.allowedFileTypes = @[ @"mpsites" ]; [NSApp activateIgnoringOtherApps:YES]; if ([savePanel runModal] == NSFileHandlingPanelCancelButton) return; [self exportSitesRevealPasswords:revealPasswords askExportPassword:^NSString *(NSString *userName) { return PearlMainQueueAwait( ^id { NSAlert *alert = [NSAlert new]; [alert addButtonWithTitle:@"Import"]; [alert addButtonWithTitle:@"Cancel"]; alert.messageText = strf( @"Master Password For\n%@", userName ); alert.informativeText = @"Enter the current master password for this user."; alert.accessoryView = [[NSSecureTextField alloc] initWithFrame:NSMakeRect( 0, 0, 200, 22 )]; [alert layout]; if ([alert runModal] == NSAlertFirstButtonReturn) return ((NSTextField *)alert.accessoryView).stringValue; else return nil; } ); } result:^(NSString *mpsites, NSError *error) { if (!mpsites || error) { PearlMainQueue( ^{ [[NSAlert alertWithError:MPError( error, @"Failed to export mpsites." )] runModal]; } ); return; } NSError *coordinateError = nil; [[[NSFileCoordinator alloc] initWithFilePresenter:nil] coordinateWritingItemAtURL:savePanel.URL options:0 error:&coordinateError byAccessor:^(NSURL *newURL) { NSError *writeError = nil; if (![mpsites writeToURL:newURL atomically:NO encoding:NSUTF8StringEncoding error:&writeError]) PearlMainQueue( ^{ [[NSAlert alertWithError:MPError( writeError, @"Could not write to the export file." )] runModal]; } ); }]; if (coordinateError) PearlMainQueue( ^{ [[NSAlert alertWithError:MPError( coordinateError, @"Could not gain access to the export file." )] runModal]; } ); }]; } - (void)updateUsers { [[[self.usersItem submenu] itemArray] enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { if (idx > 2) [[self.usersItem submenu] removeItem:obj]; }]; NSManagedObjectContext *mainContext = [MPMacAppDelegate managedObjectContextForMainThreadIfReady]; if (!mainContext) { self.createUserItem.title = @"New User (Not ready)"; self.createUserItem.enabled = NO; self.createUserItem.toolTip = @"Please wait until the app is fully loaded."; self.deleteUserItem.title = @"Delete User (Not ready)"; self.deleteUserItem.enabled = NO; self.deleteUserItem.toolTip = @"Please wait until the app is fully loaded."; [self.usersItem.submenu addItemWithTitle:@"Loading..." action:NULL keyEquivalent:@""].enabled = NO; return; } MPUserEntity *mainActiveUser = [self activeUserInContext:mainContext]; self.createUserItem.title = @"New User"; self.createUserItem.enabled = YES; self.createUserItem.toolTip = nil; self.deleteUserItem.title = mainActiveUser? @"Delete User": @"Delete User (None Selected)"; self.deleteUserItem.enabled = mainActiveUser != nil; self.deleteUserItem.toolTip = mainActiveUser? nil: @"First select the user to delete."; NSError *error = nil; NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass( [MPUserEntity class] )]; fetchRequest.sortDescriptors = @[ [NSSortDescriptor sortDescriptorWithKey:@"lastUsed" ascending:NO] ]; NSArray *users = [mainContext executeFetchRequest:fetchRequest error:&error]; if (!users) MPError( error, @"Failed to load users." ); if (![users count]) { NSMenuItem *noUsersItem = [self.usersItem.submenu addItemWithTitle:@"No users" action:NULL keyEquivalent:@""]; noUsersItem.enabled = NO; noUsersItem.toolTip = @"Begin by creating a user."; } self.usersItem.state = NSMixedState; for (MPUserEntity *user in users) { NSMenuItem *userItem = [[NSMenuItem alloc] initWithTitle:user.name action:@selector( selectUser: ) keyEquivalent:@""]; [userItem setTarget:self]; [userItem setRepresentedObject:user.permanentObjectID]; [[self.usersItem submenu] addItem:userItem]; if (!mainActiveUser && [user.name isEqualToString:[MPMacConfig get].usedUserName]) [super setActiveUser:mainActiveUser = user]; if ([mainActiveUser isEqual:user]) { userItem.state = NSOnState; self.usersItem.state = NSOffState; } else userItem.state = NSOffState; } [self updateMenuItems]; } - (void)showMenu { [self updateMenuItems]; [self.statusView popUpStatusItemMenu:self.statusView.menu]; } - (void)updateMenuItems { MPUserEntity *activeUser = [self activeUserForMainThread]; // if (!(self.showItem.enabled = ![self.sitesWindowController.window isVisible])) { // self.showItem.title = @"Show (Showing)"; // self.showItem.toolTip = @"Master Password is already showing."; // } // else if (!(self.showItem.enabled = (activeUser != nil))) { // self.showItem.title = @"Show (No user)"; // self.showItem.toolTip = @"First select the user to show passwords for."; // } // else { // self.showItem.title = @"Show"; // self.showItem.toolTip = nil; // } if (self.key) { self.lockItem.title = @"Lock"; self.lockItem.enabled = YES; self.lockItem.toolTip = nil; } else { self.lockItem.title = @"Lock (Locked)"; self.lockItem.enabled = NO; self.lockItem.toolTip = @"Master Password is currently locked."; } BOOL loginItemEnabled = [self loginItemEnabled]; self.openAtLoginItem.state = loginItemEnabled? NSOnState: NSOffState; self.showFullScreenItem.state = [[MPMacConfig get].fullScreen boolValue]? NSOnState: NSOffState; self.initialWindowController.openAtLoginButton.state = loginItemEnabled? NSOnState: NSOffState; self.rememberPasswordItem.state = [[MPConfig get].rememberLogin boolValue]? NSOnState: NSOffState; self.savePasswordItem.state = activeUser.saveKey? NSOnState: NSOffState; if (!activeUser) { self.savePasswordItem.title = @"Save Password (No user)"; self.savePasswordItem.enabled = NO; self.savePasswordItem.toolTip = @"First select your user and unlock by showing the Master Password window."; } else if (!self.key) { self.savePasswordItem.title = @"Save Password (Locked)"; self.savePasswordItem.enabled = NO; self.savePasswordItem.toolTip = @"First unlock by showing the Master Password window."; } else { self.savePasswordItem.title = @"Save Password"; self.savePasswordItem.enabled = YES; self.savePasswordItem.toolTip = nil; } } #pragma mark - PearlConfigDelegate - (void)didUpdateConfigForKey:(SEL)configKey fromValue:(id)oldValue { [[NSNotificationCenter defaultCenter] postNotificationName:MPCheckConfigNotification object:NSStringFromSelector( configKey )]; } #pragma mark - Crashlytics - (NSDictionary *)crashlyticsInfo { static NSDictionary *crashlyticsInfo = nil; if (crashlyticsInfo == nil) crashlyticsInfo = [[NSDictionary alloc] initWithContentsOfURL: [[NSBundle mainBundle] URLForResource:@"Crashlytics" withExtension:@"plist"]]; return crashlyticsInfo; } - (NSString *)crashlyticsAPIKey { NSString *crashlyticsAPIKey = NSNullToNil( [[self crashlyticsInfo] valueForKeyPath:@"API Key"] ); if (![crashlyticsAPIKey length]) wrn( @"Crashlytics API key not set. Crash logs won't be recorded." ); return crashlyticsAPIKey; } @end