From eb635e54d927fdb6377253945edb1761229112ee Mon Sep 17 00:00:00 2001 From: Patrick Wardle Date: Tue, 20 Dec 2016 21:38:26 -1000 Subject: [PATCH] -improved cmdline processing -ignore 'duplicate' events -added code/UI for whitelist popup --- Installer/main.m | 66 ++++------ LoginItem/AVMonitor.h | 20 +-- LoginItem/AVMonitor.m | 185 +++++++++++++++++++++++++--- LoginItem/RemeberWindowController.h | 26 ++++ LoginItem/RemeberWindowController.m | 105 ++++++++++++++++ LoginItem/RememberPopup.xib | 77 ++++++++++++ OverSight.xcodeproj/project.pbxproj | 12 +- Shared/AboutWindowController.h | 2 +- Shared/Consts.h | 20 +++ Shared/InfoWindowController.m | 2 +- 10 files changed, 450 insertions(+), 65 deletions(-) create mode 100644 LoginItem/RemeberWindowController.h create mode 100644 LoginItem/RemeberWindowController.m create mode 100644 LoginItem/RememberPopup.xib diff --git a/Installer/main.m b/Installer/main.m index 11eea59..14ee93f 100644 --- a/Installer/main.m +++ b/Installer/main.m @@ -21,7 +21,7 @@ int main(int argc, const char * argv[]) //handle '-install' / '-uninstall' // ->this performs non-UI logic for easier automated deployment if( (argc >= 2) && - (YES != [[NSString stringWithUTF8String:argv[1]] hasPrefix:@"-psn_"]) ) + ( (0 == strcmp(argv[1], CMD_INSTALL)) || (0 == strcmp(argv[1], CMD_UNINSTALL)) ) ) { //first check rooot if(0 != geteuid()) @@ -69,56 +69,44 @@ int main(int argc, const char * argv[]) //happy retVar = 0; } + + //bail + goto bail; - //invalid arg - else + }//args + + //check for r00t + // ->then spawn self via auth exec + if(0 != geteuid()) + { + //dbg msg + logMsg(LOG_DEBUG, @"non-root installer instance"); + + //spawn as root + if(YES != spawnAsRoot(argv[0])) { //err msg - printf("\nERROR: '%s', is an invalid option\n\n", argv[1]); + logMsg(LOG_ERR, @"failed to spawn self as r00t"); //bail goto bail; } - }//args + //happy + retVar = 0; + } - //no args + //otherwise + // ->just kick off app, as we're root now else { - //check for r00t - // ->then spawn self via auth exec - if(0 != geteuid()) - { - //dbg msg - logMsg(LOG_DEBUG, @"non-root installer instance"); - - //spawn as root - if(YES != spawnAsRoot(argv[0])) - { - //err msg - logMsg(LOG_ERR, @"failed to spawn self as r00t"); - - //bail - goto bail; - } - - //happy - retVar = 0; - } + //dbg msg + logMsg(LOG_DEBUG, @"root installer instance"); - //otherwise - // ->just kick off app, as we're root now - else - { - //dbg msg - logMsg(LOG_DEBUG, @"root installer instance"); - - //app away - retVar = NSApplicationMain(argc, (const char **)argv); - } - - }//no args - + //app away + retVar = NSApplicationMain(argc, (const char **)argv); + } + }//pool //bail diff --git a/LoginItem/AVMonitor.h b/LoginItem/AVMonitor.h index b79ddb1..a7dece3 100644 --- a/LoginItem/AVMonitor.h +++ b/LoginItem/AVMonitor.h @@ -13,20 +13,13 @@ #import #import +#import "RemeberWindowController.h" /* DEFINES */ #define VIDEO_DISABLED 0x0 #define VIDEO_ENABLED 0x1 -#define EVENT_SOURCE @"source" -#define EVENT_DEVICE @"device" -#define EVENT_DEVICE_STATUS @"status" -#define EVENT_PROCESS_ID @"processID" - -#define SOURCE_AUDIO @0x1 -#define SOURCE_VIDEO @0x2 - #define DEVICE_INACTIVE @0x0 #define DEVICE_ACTIVE @0x1 @@ -53,6 +46,17 @@ //monitor thread @property(nonatomic, retain)NSThread* videoMonitorThread; +//remember popup/window controller +@property(nonatomic, retain)RememberWindowController* rememberWindowController; + +//last event +@property(nonatomic, retain)NSDictionary* lastEvent; + +//last notification +@property(nonatomic, retain)NSString* lastNotification; + + + /* METHODS */ //kicks off thread to monitor diff --git a/LoginItem/AVMonitor.m b/LoginItem/AVMonitor.m index eb5a188..a97064b 100644 --- a/LoginItem/AVMonitor.m +++ b/LoginItem/AVMonitor.m @@ -18,9 +18,12 @@ @synthesize mic; @synthesize camera; +@synthesize lastEvent; @synthesize audioActive; @synthesize videoActive; +@synthesize lastNotification; @synthesize videoMonitorThread; +@synthesize rememberWindowController; //init -(id)init @@ -443,6 +446,9 @@ bail: // ->update menu to show (all) devices & their status [((AppDelegate*)[[NSApplication sharedApplication] delegate]).statusBarMenuController updateStatusItemMenu:devices]; + //add timestamp + event[EVENT_TIMESTAMP] = [NSDate date]; + //add device event[EVENT_DEVICE] = self.camera; @@ -695,6 +701,9 @@ bail: // ->update menu to show (all) devices & their status [((AppDelegate*)[[NSApplication sharedApplication] delegate]).statusBarMenuController updateStatusItemMenu:@[@{EVENT_DEVICE:self.mic, EVENT_DEVICE_STATUS:@(self.audioActive)},@{EVENT_DEVICE:self.camera, EVENT_DEVICE_STATUS:@(self.videoActive)}]]; + //add timestamp + event[EVENT_TIMESTAMP] = [NSDate date]; + //add device event[EVENT_DEVICE] = self.mic; @@ -860,6 +869,10 @@ bail: //notification NSUserNotification* notification = nil; + //device + // ->audio or video + NSNumber* deviceType = nil; + //title NSMutableString* title = nil; @@ -885,6 +898,32 @@ bail: //alloc log msg sysLogMsg = [NSMutableString string]; + //check if event is essentially a duplicate (facetime, etc) + if(nil != self.lastEvent) + { + //TODO: remove + //NSLog(@"difference %f", fabs([self.lastEvent[EVENT_TIMESTAMP] timeIntervalSinceDate:event[EVENT_TIMESTAMP]])); + + //less than 10 second ago? + if(fabs([self.lastEvent[EVENT_TIMESTAMP] timeIntervalSinceDate:event[EVENT_TIMESTAMP]]) < 10) + { + //same process/device/action + if( (YES == [self.lastEvent[EVENT_PROCESS_ID] isEqual:event[EVENT_PROCESS_ID]]) && + (YES == [self.lastEvent[EVENT_DEVICE] isEqual:event[EVENT_DEVICE]]) && + (YES == [self.lastEvent[EVENT_DEVICE_STATUS] isEqual:event[EVENT_DEVICE_STATUS]]) ) + { + //update + self.lastEvent = event; + + //bail to ignore + goto bail; + } + } + }//'same' event check + + //update last event + self.lastEvent = event; + //always (manually) load preferences preferences = [NSDictionary dictionaryWithContentsOfFile:[APP_PREFERENCES stringByExpandingTildeInPath]]; @@ -899,19 +938,24 @@ bail: goto bail; } - //set title - // ->audio device + //set device and title for audio if(YES == [event[EVENT_DEVICE] isKindOfClass:NSClassFromString(@"AVCaptureHALDevice")]) { //add [title appendString:@"Audio Device"]; + + //set device + deviceType = SOURCE_AUDIO; + } - //add source - // ->video device + //set device and title for video else { //add [title appendString:@"Video Device"]; + + //set device + deviceType = SOURCE_VIDEO; } //add action @@ -953,14 +997,14 @@ bail: processName = getProcessName([event[EVENT_PROCESS_ID] intValue]); //set other button title - notification.otherButtonTitle = @"allowz"; + notification.otherButtonTitle = @"allow"; //set action title notification.actionButtonTitle = @"block"; - //set pid in user info - // ->allows code to try kill proc (later) if user clicks 'block' - notification.userInfo = @{EVENT_PROCESS_ID:event[EVENT_PROCESS_ID]}; + //set pid/name/device into user info + // ->allows code to whitelist proc and/or kill proc (later) if user clicks 'block' + notification.userInfo = @{EVENT_PROCESS_ID:event[EVENT_PROCESS_ID], EVENT_PROCESS_NAME:processName, EVENT_DEVICE:deviceType}; //set details // ->name of process using it / icon too? @@ -999,6 +1043,9 @@ bail: //set subtitle [notification setSubtitle:details]; + //set id + notification.identifier = [[NSUUID UUID] UUIDString]; + //set notification [[NSUserNotificationCenter defaultUserNotificationCenter] setDelegate:self]; @@ -1058,6 +1105,24 @@ bail: //dbg msg logMsg(LOG_DEBUG, [NSString stringWithFormat:@"user responded to notification: %@", notification]); + //ignore if this notification was already seen + // ->need this logic, since have to determine if 'allow' was invoke indirectly + if(nil != self.lastNotification) + { + //same? + if(YES == [self.lastNotification isEqualToString:notification.identifier]) + { + //update + self.lastNotification = notification.identifier; + + //ignore + goto bail; + } + } + + //update + self.lastNotification = notification.identifier; + //for alerts without an action // ->don't need to do anything! if(YES != notification.hasActionButton) @@ -1092,11 +1157,33 @@ bail: syslog(LOG_ERR, "%s\n", sysLogMsg.UTF8String); } - //when user clicks 'allow' - // ->show popup w/ option to whitelist - if(notification.activationType == NSUserNotificationActivationTypeAdditionalActionClicked) + //check if user clicked 'allow' via user info (since OS doesn't directly deliver this) + // ->if allow was clicked, show a popup w/ option to rember ('whitelist') the application + if( (nil != notification.userInfo) && + (NSUserNotificationActivationTypeAdditionalActionClicked == [notification.userInfo[@"activationType"] integerValue]) ) { - //TODO: show popup + //alloc/init settings window + if(nil == self.rememberWindowController) + { + //alloc/init + rememberWindowController = [[RememberWindowController alloc] initWithWindowNibName:@"RememberPopup"]; + } + + //center window + [[self.rememberWindowController window] center]; + + //show it + [self.rememberWindowController showWindow:self]; + + //manually configure + // ->invoke here as the outlets will be set + [self.rememberWindowController configure:notification]; + + //make it key window + [self.rememberWindowController.window makeKeyAndOrderFront:self]; + + //make window front + [NSApp activateIgnoringOtherApps:YES]; //dbg msg logMsg(LOG_DEBUG, @"user clicked 'allow'"); @@ -1104,7 +1191,7 @@ bail: //when user clicks 'block' // ->kill the process to block it - else if(notification.activationType == NSUserNotificationActivationTypeActionButtonClicked) + else if(NSUserNotificationActivationTypeActionButtonClicked == notification.activationType) { //dbg msg logMsg(LOG_DEBUG, @"user clicked 'block'"); @@ -1140,7 +1227,6 @@ bail: { //err msg logMsg(LOG_ERR, [NSString stringWithFormat:@"failed to kill/block: %@", processID]); - } //close connection @@ -1158,6 +1244,75 @@ bail: return; } +//manually monitor delivered notifications to see if user closes alert +// ->can't detect 'allow' otherwise :/ (see: http://stackoverflow.com/questions/21110714/mac-os-x-nsusernotificationcenter-notification-get-dismiss-event-callback) +-(void)userNotificationCenter:(NSUserNotificationCenter *)center didDeliverNotification:(NSUserNotification *)notification +{ + //flag + __block BOOL notificationStillPresent; + + //user dictionary + __block NSMutableDictionary* userInfo = nil; + + //only process notifications have 'allow' / 'block' + if(YES == notification.hasActionButton) + { + //monitor in background to see if alert was dismissed + // ->invokes normal 'didActivateNotification' callback when alert is dimsissed + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), + ^{ + //monitor all delivered notifications until it goes away + do { + + //reset + notificationStillPresent = NO; + + //check all delivered notifications + for (NSUserNotification *nox in [[NSUserNotificationCenter defaultUserNotificationCenter] deliveredNotifications]) + { + //check + if(YES == [nox.identifier isEqualToString:notification.identifier]) + { + //found! + notificationStillPresent = YES; + + //exit loop + break; + } + } + + //nap if notification is still there + if(YES == notificationStillPresent) + { + //nap + [NSThread sleepForTimeInterval:0.25f]; + } + + //keep monitoring until its gone + } while(YES == notificationStillPresent); + + //alert was dismissed + // ->invoke 'didActivateNotification' to process if it was an 'allow/block' alert + dispatch_async(dispatch_get_main_queue(), + ^{ + //grab user info dictionary + userInfo = [notification.userInfo mutableCopy]; + + //add activation type + userInfo[@"activationType"] = [NSNumber numberWithInteger:NSUserNotificationActivationTypeAdditionalActionClicked]; + + //update + notification.userInfo = userInfo; + + //deliver + [self userNotificationCenter:center didActivateNotification:notification]; + }); + }); + } + + return; +} + //monitor for new procs (video only at the moment) // ->runs until video is no longer in use (set elsewhere) -(void)monitor4Procs @@ -1212,7 +1367,7 @@ bail: } //generate notification - [self generateNotification:@{EVENT_DEVICE:self.camera, EVENT_DEVICE_STATUS:DEVICE_ACTIVE, EVENT_PROCESS_ID:processID}]; + [self generateNotification:@{EVENT_TIMESTAMP:[NSDate date], EVENT_DEVICE:self.camera, EVENT_DEVICE_STATUS:DEVICE_ACTIVE, EVENT_PROCESS_ID:processID}]; } //signal sema diff --git a/LoginItem/RemeberWindowController.h b/LoginItem/RemeberWindowController.h new file mode 100644 index 0000000..d3401b2 --- /dev/null +++ b/LoginItem/RemeberWindowController.h @@ -0,0 +1,26 @@ +// +// AboutWindowController.h +// OverSight +// +// Created by Patrick Wardle on 7/15/16. +// Copyright (c) 2016 Objective-See. All rights reserved. +// + +#import + +@interface RememberWindowController : NSWindowController +{ + +} + +/* PROPERTIES */ + +//version label/string +@property (weak) IBOutlet NSTextField *windowText; + +/* METHODS */ + +//configure window w/ dynamic text +-(void)configure:(NSUserNotification*)notification; + +@end diff --git a/LoginItem/RemeberWindowController.m b/LoginItem/RemeberWindowController.m new file mode 100644 index 0000000..fb0e01f --- /dev/null +++ b/LoginItem/RemeberWindowController.m @@ -0,0 +1,105 @@ +// +// RememberWindowController.m +// OverSight +// +// Created by Patrick Wardle on 7/15/16. +// Copyright (c) 2016 Objective-See. All rights reserved. +// + +#import "Consts.h" +#import "Utilities.h" +#import "RemeberWindowController.h" + +@implementation RememberWindowController + +//@synthesize versionLabel; + +//automatically called when nib is loaded +// ->center window +-(void)awakeFromNib +{ + //center + [self.window center]; +} + +//automatically invoked when window is loaded +// ->set to white +-(void)windowDidLoad +{ + //super + [super windowDidLoad]; + + //make white + [self.window setBackgroundColor: NSColor.whiteColor]; + + //set version sting + //[self.versionLabel setStringValue:[NSString stringWithFormat:@"version: %@", getAppVersion()]]; + + return; +} + +/* +//automatically invoked when window is closing +// ->make ourselves unmodal +-(void)windowWillClose:(NSNotification *)notification +{ + //make un-modal + [[NSApplication sharedApplication] stopModal]; + + return; +} +*/ + +//configure window w/ dynamic text +-(void)configure:(NSUserNotification*)notification +{ + //process ID + NSNumber* processID = nil; + + //process name + NSString* processName = nil; + + //device type + NSString* deviceType = nil; + + //grab process id + processID = notification.userInfo[EVENT_PROCESS_ID]; + + //grab process name + processName = notification.userInfo[EVENT_PROCESS_NAME]; + + //set device type for audio + if(SOURCE_AUDIO.intValue == [notification.userInfo[EVENT_DEVICE] intValue]) + { + //set + deviceType = @"mic"; + } + //set device type for mic + else if(SOURCE_VIDEO.intValue == [notification.userInfo[EVENT_DEVICE] intValue]) + { + //set + deviceType = @"camera"; + } + + //set text + [self.windowText setStringValue:[NSString stringWithFormat:@"always allow %@ (%@) to use the %@?", processName, processID, deviceType]]; + + return; +} + +//automatically invoked when user clicks button 'yes' / 'no' +-(IBAction)buttonHandler:(id)sender +{ + //handle 'always allow' (whitelist) button + if(BUTTON_ALWAYS_ALLOW == ((NSButton*)sender).tag) + { + //TODO: whitelist + } + + //always close + [self.window close]; + + + return; +} +@end diff --git a/LoginItem/RememberPopup.xib b/LoginItem/RememberPopup.xib new file mode 100644 index 0000000..b5424be --- /dev/null +++ b/LoginItem/RememberPopup.xib @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OverSight.xcodeproj/project.pbxproj b/OverSight.xcodeproj/project.pbxproj index fb802c0..d2aa55c 100644 --- a/OverSight.xcodeproj/project.pbxproj +++ b/OverSight.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 7D17CFE51D8133840017B475 /* AVMonitor.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D17CFE11D81121E0017B475 /* AVMonitor.m */; }; 7D17D0101D8136C60017B475 /* OverSightXPC.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 7DC9C8121D641A350017D143 /* OverSightXPC.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7D3B524C1D9B3A74006568D9 /* libbsm.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 7D9A7DED1D8CACE30091C1AF /* libbsm.tbd */; }; + 7D6216731E078D65002C1774 /* RemeberWindowController.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D6216701E078C09002C1774 /* RemeberWindowController.m */; }; + 7D6216741E07BB27002C1774 /* RememberPopup.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7D62166E1E078BDD002C1774 /* RememberPopup.xib */; }; 7D62457C1D84FB8900870565 /* Enumerator.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D62457B1D84FB8900870565 /* Enumerator.m */; }; 7D6245801D85348E00870565 /* Utilities.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D62457F1D85348E00870565 /* Utilities.m */; }; 7D6245851D87C43900870565 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7D6245841D87C43900870565 /* Images.xcassets */; }; @@ -108,6 +110,9 @@ 7D17CFE11D81121E0017B475 /* AVMonitor.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AVMonitor.m; sourceTree = ""; }; 7D17CFE21D81121E0017B475 /* AVMonitor.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AVMonitor.h; sourceTree = ""; }; 7D32457A1DA59A3700AE0711 /* main.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = main.h; sourceTree = ""; }; + 7D62166E1E078BDD002C1774 /* RememberPopup.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = RememberPopup.xib; sourceTree = ""; }; + 7D6216701E078C09002C1774 /* RemeberWindowController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RemeberWindowController.m; sourceTree = ""; }; + 7D6216721E078C13002C1774 /* RemeberWindowController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RemeberWindowController.h; sourceTree = ""; }; 7D62457A1D84FB8900870565 /* Enumerator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Enumerator.h; sourceTree = ""; }; 7D62457B1D84FB8900870565 /* Enumerator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Enumerator.m; sourceTree = ""; }; 7D62457E1D85348E00870565 /* Utilities.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Utilities.h; sourceTree = ""; }; @@ -277,6 +282,9 @@ 8B5755C319DA3F9300799E6B /* LoginItem */ = { isa = PBXGroup; children = ( + 7D6216721E078C13002C1774 /* RemeberWindowController.h */, + 7D6216701E078C09002C1774 /* RemeberWindowController.m */, + 7D62166E1E078BDD002C1774 /* RememberPopup.xib */, 7D17C5131D658FE20066232A /* Images */, 8B5755C919DA3F9300799E6B /* AppDelegate.m */, 7D17CFE21D81121E0017B475 /* AVMonitor.h */, @@ -363,7 +371,7 @@ 8B57559319DA3E9500799E6B /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 0800; + LastUpgradeCheck = 0810; ORGANIZATIONNAME = "Cory Bohon"; TargetAttributes = { 7DC9C8111D641A350017D143 = { @@ -435,6 +443,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7D6216741E07BB27002C1774 /* RememberPopup.xib in Resources */, 7D9A7DE81D893E4F0091C1AF /* InfoWindow.xib in Resources */, 7D6245951D87E14800870565 /* MainMenu.xib in Resources */, 7D6245931D87E13200870565 /* icon.png in Resources */, @@ -479,6 +488,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7D6216731E078D65002C1774 /* RemeberWindowController.m in Sources */, 7D6245881D87C57600870565 /* Logging.m in Sources */, 7D6245801D85348E00870565 /* Utilities.m in Sources */, 7D17CFE51D8133840017B475 /* AVMonitor.m in Sources */, diff --git a/Shared/AboutWindowController.h b/Shared/AboutWindowController.h index 01394f2..d5b6493 100644 --- a/Shared/AboutWindowController.h +++ b/Shared/AboutWindowController.h @@ -21,7 +21,7 @@ /* METHODS */ //invoked when user clicks 'more info' button -// ->open KK's webpage +// ->open OverSights webpage - (IBAction)moreInfo:(id)sender; @end diff --git a/Shared/Consts.h b/Shared/Consts.h index a1af82d..bb01528 100644 --- a/Shared/Consts.h +++ b/Shared/Consts.h @@ -107,6 +107,26 @@ //path to facetime #define FACE_TIME @"/Applications/FaceTime.app/Contents/MacOS/FaceTime" +//event keys +//#define EVENT_SOURCE @"source" +#define EVENT_DEVICE @"device" +#define EVENT_TIMESTAMP @"timestamp" +#define EVENT_DEVICE_STATUS @"status" +#define EVENT_PROCESS_ID @"processID" +#define EVENT_PROCESS_NAME @"processName" + + +//source audio +#define SOURCE_AUDIO @0x1 + +//source video +#define SOURCE_VIDEO @0x2 + +//always allow button +#define BUTTON_ALWAYS_ALLOW 100 + +//no/close button +#define BUTTON_NO 101 diff --git a/Shared/InfoWindowController.m b/Shared/InfoWindowController.m index 6d61cf8..20f8e6e 100644 --- a/Shared/InfoWindowController.m +++ b/Shared/InfoWindowController.m @@ -1,6 +1,6 @@ // // PrefsWindowController.m -// KnockKnock +// OverSight // // Created by Patrick Wardle on 2/6/15. // Copyright (c) 2015 Objective-See, LLC. All rights reserved.