// // AppController.m // SelfControl // // Created by Charlie Stigler on 1/29/09. // Copyright 2009 Eyebeam. // This file is part of SelfControl. // // SelfControl 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. // This program 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 should have received a copy of the GNU General Public License // along with this program. If not, see . #import "AppController.h" #import "MASPreferencesWindowController.h" #import "PreferencesGeneralViewController.h" #import "PreferencesAdvancedViewController.h" #import "SCTimeIntervalFormatter.h" #import #import #import "SCSettings.h" #import #import "SCXPCClient.h" #import "HostFileBlocker.h" #import "SCBlockFileReaderWriter.h" @interface AppController () {} @property (atomic, strong, readwrite) SCXPCClient* xpc; @end @implementation AppController { NSWindowController* getStartedWindowController; } @synthesize addingBlock; - (AppController*) init { if(self = [super init]) { defaults_ = [NSUserDefaults standardUserDefaults]; [defaults_ registerDefaults: SCConstants.defaultUserDefaults]; self.addingBlock = false; // refreshUILock_ is a lock that prevents a race condition by making the refreshUserInterface // method alter the blockIsOn variable atomically (will no longer be necessary once we can // use properties). refreshUILock_ = [[NSLock alloc] init]; } return self; } - (IBAction)updateTimeSliderDisplay:(id)sender { NSInteger numMinutes = [defaults_ integerForKey: @"BlockDuration"]; // if the duration is larger than we can display on our slider // chop it down to our max display value so the user doesn't // accidentally start a much longer block than intended if (numMinutes > blockDurationSlider_.maxValue) { [self setDefaultsBlockDurationOnMainThread: @(floor(blockDurationSlider_.maxValue))]; numMinutes = [defaults_ integerForKey: @"BlockDuration"]; } // Time-display code cleaned up thanks to the contributions of many users NSString* timeString = [self timeSliderDisplayStringFromNumberOfMinutes:numMinutes]; [blockSliderTimeDisplayLabel_ setStringValue:timeString]; [submitButton_ setEnabled: (numMinutes > 0) && ([[defaults_ arrayForKey: @"Blocklist"] count] > 0)]; } - (NSString *)timeSliderDisplayStringFromNumberOfMinutes:(NSInteger)numberOfMinutes { static NSCalendar* gregorian = nil; if (gregorian == nil) { gregorian = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian]; } NSRange secondsRangePerMinute = [gregorian rangeOfUnit:NSCalendarUnitSecond inUnit:NSCalendarUnitMinute forDate:[NSDate date]]; NSUInteger numberOfSecondsPerMinute = NSMaxRange(secondsRangePerMinute); NSTimeInterval numberOfSecondsSelected = (NSTimeInterval)(numberOfSecondsPerMinute * numberOfMinutes); NSString* displayString = [self timeSliderDisplayStringFromTimeInterval:numberOfSecondsSelected]; return displayString; } - (NSString *)timeSliderDisplayStringFromTimeInterval:(NSTimeInterval)numberOfSeconds { static SCTimeIntervalFormatter* formatter = nil; if (formatter == nil) { formatter = [[SCTimeIntervalFormatter alloc] init]; } NSString* formatted = [formatter stringForObjectValue:@(numberOfSeconds)]; return formatted; } - (IBAction)addBlock:(id)sender { if ([self blockIsRunning]) { // This method shouldn't be getting called, a block is on so the Start button should be disabled. NSError* err = [SCErr errorWithCode: 104]; [SCSentry captureError: err]; [NSApp presentError: err]; return; } if([[defaults_ arrayForKey: @"Blocklist"] count] == 0) { // Since the Start button should be disabled when the blocklist has no entries, // this should definitely not be happening. Exit. NSError* err = [SCErr errorWithCode: 101]; [SCSentry captureError: err]; [NSApp presentError: err]; return; } if([defaults_ boolForKey: @"VerifyInternetConnection"] && ![self networkConnectionIsAvailable]) { NSAlert* networkUnavailableAlert = [[NSAlert alloc] init]; [networkUnavailableAlert setMessageText: NSLocalizedString(@"No network connection detected", "No network connection detected message")]; [networkUnavailableAlert setInformativeText:NSLocalizedString(@"A block cannot be started without a working network connection. You can override this setting in Preferences.", @"Message when network connection is unavailable")]; [networkUnavailableAlert addButtonWithTitle: NSLocalizedString(@"Cancel", "Cancel button")]; [networkUnavailableAlert addButtonWithTitle: NSLocalizedString(@"Network Diagnostics...", @"Network Diagnostics button")]; if([networkUnavailableAlert runModal] == NSAlertFirstButtonReturn) return; // If the user selected Network Diagnostics launch an assisant to help them. // apple.com is an arbitrary host chosen to pass to Network Diagnostics. CFURLRef url = CFURLCreateWithString(NULL, CFSTR("http://apple.com"), NULL); CFNetDiagnosticRef diagRef = CFNetDiagnosticCreateWithURL(kCFAllocatorDefault, url); CFNetDiagnosticDiagnoseProblemInteractively(diagRef); return; } [timerWindowController_ resetStrikes]; [NSThread detachNewThreadSelector: @selector(installBlock) toTarget: self withObject: nil]; } - (void)refreshUserInterface { // UI updates are for the main thread only! if (![NSThread isMainThread]) { dispatch_sync(dispatch_get_main_queue(), ^{ [self refreshUserInterface]; }); return; } if(![refreshUILock_ tryLock]) { // already refreshing the UI, no need to wait and do it again return; } BOOL blockWasOn = blockIsOn; blockIsOn = [self blockIsRunning]; if(blockIsOn) { // block is on if(!blockWasOn) { // if we just switched states to on... [self closeTimerWindow]; [self showTimerWindow]; [initialWindow_ close]; [self closeDomainList]; } } else { // block is off if(blockWasOn) { // if we just switched states to off... [timerWindowController_ blockEnded]; // Makes sure the domain list will refresh when it comes back [self closeDomainList]; NSWindow* mainWindow = [NSApp mainWindow]; // We don't necessarily want the initial window to be key and front, // but no other message seems to show it properly. [initialWindow_ makeKeyAndOrderFront: self]; // So we work around it and make key and front whatever was the main window [mainWindow makeKeyAndOrderFront: self]; // make sure the dock badge is cleared [[NSApp dockTile] setBadgeLabel: nil]; [self closeTimerWindow]; } [self updateTimeSliderDisplay: blockDurationSlider_]; if([defaults_ integerForKey: @"BlockDuration"] != 0 && [[defaults_ arrayForKey: @"Blocklist"] count] != 0 && !self.addingBlock) { [submitButton_ setEnabled: YES]; } else { [submitButton_ setEnabled: NO]; } // If we're adding a block, we want buttons disabled. if(!self.addingBlock) { [blockDurationSlider_ setEnabled: YES]; [editBlocklistButton_ setEnabled: YES]; [submitButton_ setTitle: NSLocalizedString(@"Start", @"Start button")]; } else { [blockDurationSlider_ setEnabled: NO]; [editBlocklistButton_ setEnabled: NO]; [submitButton_ setTitle: NSLocalizedString(@"Loading", @"Loading button")]; } // if block's off, and we haven't shown it yet, show the first-time modal if (![defaults_ boolForKey: @"GetStartedShown"]) { [defaults_ setBool: YES forKey: @"GetStartedShown"]; [self showGetStartedWindow: self]; } } // finally: if the helper tool marked that it detected tampering, make sure // we follow through and set the cheater wallpaper (helper tool can't do it itself) if ([settings_ boolForKey: @"TamperingDetected"]) { NSURL* cheaterBackgroundURL = [[NSBundle mainBundle] URLForResource: @"cheater-background" withExtension: @"png"]; NSArray* screens = [NSScreen screens]; for (NSScreen* screen in screens) { NSError* err; [[NSWorkspace sharedWorkspace] setDesktopImageURL: cheaterBackgroundURL forScreen: screen options: @{} error: &err]; } [settings_ setValue: @NO forKey: @"TamperingDetected"]; } // Display "blocklist" or "allowlist" as appropriate NSString* listType = [defaults_ boolForKey: @"BlockAsWhitelist"] ? @"Allowlist" : @"Blocklist"; NSString* editListString = NSLocalizedString(([NSString stringWithFormat: @"Edit %@", listType]), @"Edit list button / menu item"); editBlocklistButton_.title = editListString; editBlocklistMenuItem_.title = editListString; [refreshUILock_ unlock]; } - (void)handleConfigurationChangedNotification { [SCSentry addBreadcrumb: @"Received configuration changed notification" category: @"app"]; // if our configuration changed, we should assume the settings may have changed [[SCSettings sharedSettings] reloadSettings]; // and our interface may need to change to match! [self refreshUserInterface]; } - (void)showTimerWindow { if(timerWindowController_ == nil) { [[NSBundle mainBundle] loadNibNamed: @"TimerWindow" owner: self topLevelObjects: nil]; } else { [[timerWindowController_ window] makeKeyAndOrderFront: self]; [[timerWindowController_ window] center]; } } - (void)closeTimerWindow { [timerWindowController_ close]; timerWindowController_ = nil; } - (IBAction)openPreferences:(id)sender { [SCSentry addBreadcrumb: @"Opening preferences window" category: @"app"]; if (preferencesWindowController_ == nil) { NSViewController* generalViewController = [[PreferencesGeneralViewController alloc] init]; NSViewController* advancedViewController = [[PreferencesAdvancedViewController alloc] init]; NSString* title = NSLocalizedString(@"Preferences", @"Common title for Preferences window"); preferencesWindowController_ = [[MASPreferencesWindowController alloc] initWithViewControllers: @[generalViewController, advancedViewController] title: title]; } [preferencesWindowController_ showWindow: nil]; } - (IBAction)showGetStartedWindow:(id)sender { [SCSentry addBreadcrumb: @"Showing \"Get Started\" window" category: @"app"]; if (!getStartedWindowController) { getStartedWindowController = [[NSWindowController alloc] initWithWindowNibName: @"FirstTime"]; } [getStartedWindowController.window center]; [getStartedWindowController.window makeKeyAndOrderFront: nil]; [getStartedWindowController showWindow: nil]; } - (void)applicationWillFinishLaunching:(NSNotification *)notification { // For test runs, we don't want to pop up the dialog to move to the Applications folder, as it breaks the tests if (NSProcessInfo.processInfo.environment[@"XCTestConfigurationFilePath"] == nil) { PFMoveToApplicationsFolderIfNecessary(); } } - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { [NSApplication sharedApplication].delegate = self; [SCSentry startSentry: @"org.eyebeam.SelfControl"]; settings_ = [SCSettings sharedSettings]; // go copy over any preferences from legacy setting locations // (we won't clear any old data yet - we leave that to the daemon) if ([SCMigrationUtilities legacySettingsFoundForCurrentUser]) { [SCMigrationUtilities copyLegacySettingsToDefaults]; } // start up our daemon XPC self.xpc = [SCXPCClient new]; [self.xpc connectToHelperTool]; // if we don't have a connection within 0.5 seconds, // OR we get back a connection with an old daemon version // AND we're running a modern block (which should have a daemon running it) // something's wrong with our app-daemon connection. This probably means one of two things: // 1. The daemon got unloaded somehow and failed to restart. This is a big problem because the block won't come off. // 2. The daemon doesn't want to talk to us anymore, potentially because we've changed our signing certificate. This is a // smaller problem, but still not great because the app can't communicate anything to the daemon. // 3. There's a daemon but it's an old version, and should be replaced. // in any case, let's go try to reinstall the daemon // (we debounce this call so it happens only once, after the connection has been invalidated for an extended period) if ([SCBlockUtilities modernBlockIsRunning]) { [NSTimer scheduledTimerWithTimeInterval: 0.5 repeats: NO block:^(NSTimer * _Nonnull timer) { [self.xpc getVersion:^(NSString * _Nonnull daemonVersion, NSError * _Nonnull error) { if (error == nil) { if ([SELFCONTROL_VERSION_STRING compare: daemonVersion options: NSNumericSearch] == NSOrderedDescending) { NSLog(@"Daemon version of %@ is out of date (current version is %@).", daemonVersion, SELFCONTROL_VERSION_STRING); [SCSentry addBreadcrumb: @"Detected out-of-date daemon" category: @"app"]; [self reinstallDaemon]; } else { [SCSentry addBreadcrumb: @"Detected up-to-date daemon" category:@"app"]; NSLog(@"Daemon version of %@ is up-to-date!", daemonVersion); } } else { NSLog(@"ERROR: Fetching daemon version failed with error %@", error); [self reinstallDaemon]; } }]; }]; } // Register observers on both distributed and normal notification centers // to receive notifications from the helper tool and the other parts of the // main SelfControl app. Note that they are divided thusly because distributed // notifications are very expensive and should be minimized. [[NSDistributedNotificationCenter defaultCenter] addObserver: self selector: @selector(handleConfigurationChangedNotification) name: @"SCConfigurationChangedNotification" object: nil suspensionBehavior: NSNotificationSuspensionBehaviorDeliverImmediately]; [[NSNotificationCenter defaultCenter] addObserver: self selector: @selector(handleConfigurationChangedNotification) name: @"SCConfigurationChangedNotification" object: nil]; [initialWindow_ center]; // We'll set blockIsOn to whatever is NOT right, so that in refreshUserInterface // it'll fix it and properly refresh the user interface. blockIsOn = ![self blockIsRunning]; // Change block duration slider for hidden user defaults settings long numTickMarks = ([defaults_ integerForKey: @"MaxBlockLength"] / [defaults_ integerForKey: @"BlockLengthInterval"]) + 1; [blockDurationSlider_ setMaxValue: [defaults_ integerForKey: @"MaxBlockLength"]]; [blockDurationSlider_ setNumberOfTickMarks: numTickMarks]; [blockDurationSlider_ bind: @"value" toObject: [NSUserDefaultsController sharedUserDefaultsController] withKeyPath: @"values.BlockDuration" options: @{ NSContinuouslyUpdatesValueBindingOption: @YES }]; [self refreshUserInterface]; NSOperatingSystemVersion minRequiredVersion = (NSOperatingSystemVersion){10,10,0}; // Mountain Lion NSString* minRequiredVersionString = @"10.10 (Yosemite)"; if (![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion: minRequiredVersion]) { NSLog(@"ERROR: Unsupported version for SelfControl"); [SCSentry captureMessage: @"Unsupported operating system version"]; NSAlert* unsupportedVersionAlert = [[NSAlert alloc] init]; [unsupportedVersionAlert setMessageText: NSLocalizedString(@"Unsupported version", nil)]; [unsupportedVersionAlert setInformativeText: [NSString stringWithFormat: NSLocalizedString(@"This version of SelfControl only supports Mac OS X version %@ or higher. To download a version for older operating systems, please go to www.selfcontrolapp.com", nil), minRequiredVersionString]]; [unsupportedVersionAlert addButtonWithTitle: NSLocalizedString(@"OK", nil)]; [unsupportedVersionAlert runModal]; } } - (void)applicationWillTerminate:(NSNotification *)notification { [settings_ synchronizeSettings]; } - (void)reinstallDaemon { NSLog(@"Attempting to reinstall daemon..."); [SCSentry addBreadcrumb: @"Reinstalling daemon" category:@"app"]; [self.xpc installDaemon:^(NSError * _Nonnull error) { if (error == nil) { NSLog(@"Reinstalled daemon successfully!"); [SCSentry addBreadcrumb: @"Daemon reinstalled successfully" category:@"app"]; NSLog(@"Retrying helper tool connection..."); [self.xpc performSelectorOnMainThread: @selector(connectToHelperTool) withObject: nil waitUntilDone: YES]; } else { if (![SCMiscUtilities errorIsAuthCanceled: error]) { NSLog(@"ERROR: Reinstalling daemon failed with error %@", error); [NSApp presentError: error]; } } }]; } - (BOOL)blockIsRunning { // we'll say a block is running if we find the block info, but // also, importantly, if we find a block still going in the hosts file // that way if this happens, the user will still see the timer window - // which will let them manually clear the remaining block info after 10 seconds return [SCBlockUtilities anyBlockIsRunning] || [HostFileBlocker blockFoundInHostsFile]; } - (IBAction)showDomainList:(id)sender { [SCSentry addBreadcrumb: @"Showing domain list" category:@"app"]; if([self blockIsRunning] || self.addingBlock) { NSAlert* blockInProgressAlert = [[NSAlert alloc] init]; [blockInProgressAlert setMessageText: NSLocalizedString(@"Block in progress", @"Block in progress error title")]; [blockInProgressAlert setInformativeText:NSLocalizedString(@"The blocklist cannot be edited while a block is in progress.", @"Block in progress explanation")]; [blockInProgressAlert addButtonWithTitle: NSLocalizedString(@"OK", @"OK button")]; [blockInProgressAlert runModal]; return; } if(domainListWindowController_ == nil) { [[NSBundle mainBundle] loadNibNamed: @"DomainList" owner: self topLevelObjects: nil]; } [domainListWindowController_ showWindow: self]; } - (void)closeDomainList { [domainListWindowController_ close]; domainListWindowController_ = nil; } - (BOOL)applicationShouldTerminateAfterLastWindowClosed: (NSApplication*) theApplication { // Hack to make the application terminate after the last window is closed, but // INCLUDE the HUD-style timer window. if([[timerWindowController_ window] isVisible]) return NO; if (PFMoveIsInProgress()) return NO; return YES; } - (BOOL)networkConnectionIsAvailable { SCNetworkReachabilityFlags flags; // This method goes haywire if Google ever goes down... SCNetworkReachabilityRef target = SCNetworkReachabilityCreateWithName (kCFAllocatorDefault, "google.com"); BOOL reachable = SCNetworkReachabilityGetFlags (target, &flags); return reachable && (flags & kSCNetworkFlagsReachable) && !(flags & kSCNetworkFlagsConnectionRequired); } - (void)addToBlockList:(NSString*)host lock:(NSLock*)lock { NSLog(@"addToBlocklist: %@", host); // Note we RETRIEVE the latest list from settings (ActiveBlocklist), but we SET the new list in defaults // since the helper daemon should be the only one changing ActiveBlocklist NSMutableArray* list = [[settings_ valueForKey: @"ActiveBlocklist"] mutableCopy]; NSArray* cleanedEntries = [SCMiscUtilities cleanBlocklistEntry: host]; if (cleanedEntries.count == 0) return; for (int i = 0; i < cleanedEntries.count; i++) { NSString* entry = cleanedEntries[i]; [list addObject: entry]; } [defaults_ setValue: list forKey: @"Blocklist"]; if(![self blockIsRunning]) { // This method shouldn't be getting called, a block is not on. // so the Start button should be disabled. // Maybe the UI didn't get properly refreshed, so try refreshing it again // before we return. [self refreshUserInterface]; NSError* err = [SCErr errorWithCode: 102]; [SCSentry captureError: err]; [NSApp presentError: err]; return; } if([defaults_ boolForKey: @"VerifyInternetConnection"] && ![self networkConnectionIsAvailable]) { NSAlert* networkUnavailableAlert = [[NSAlert alloc] init]; [networkUnavailableAlert setMessageText: NSLocalizedString(@"No network connection detected", "No network connection detected message")]; [networkUnavailableAlert setInformativeText:NSLocalizedString(@"A block cannot be started without a working network connection. You can override this setting in Preferences.", @"Message when network connection is unavailable")]; [networkUnavailableAlert addButtonWithTitle: NSLocalizedString(@"Cancel", "Cancel button")]; [networkUnavailableAlert addButtonWithTitle: NSLocalizedString(@"Network Diagnostics...", @"Network Diagnostics button")]; if([networkUnavailableAlert runModal] == NSAlertFirstButtonReturn) { // User clicked cancel return; } // If the user selected Network Diagnostics, launch an assisant to help them. // apple.com is an arbitrary host chosen to pass to Network Diagnostics. CFURLRef url = CFURLCreateWithString(NULL, CFSTR("http://apple.com"), NULL); CFNetDiagnosticRef diagRef = CFNetDiagnosticCreateWithURL(kCFAllocatorDefault, url); CFNetDiagnosticDiagnoseProblemInteractively(diagRef); return; } [NSThread detachNewThreadSelector: @selector(updateActiveBlocklist:) toTarget: self withObject: lock]; } - (void)extendBlockTime:(NSInteger)minutesToAdd lock:(NSLock*)lock { // sanity check: extending a block for 0 minutes is useless; 24 hour should be impossible NSInteger maxBlockLength = [defaults_ integerForKey: @"MaxBlockLength"]; if(minutesToAdd < 1) return; if (minutesToAdd > maxBlockLength) { minutesToAdd = maxBlockLength; } // ensure block health before we try to change it if(![self blockIsRunning]) { // This method shouldn't be getting called, a block is not on. // so the Start button should be disabled. // Maybe the UI didn't get properly refreshed, so try refreshing it again // before we return. [self refreshUserInterface]; NSError* err = [SCErr errorWithCode: 103]; [SCSentry captureError: err]; [NSApp presentError: err]; return; } [self updateBlockEndDate: lock minutesToAdd: minutesToAdd]; // [NSThread detachNewThreadSelector: @selector(extendBlockDuration:) // toTarget: self // withObject: @{ // @"lock": lock, // @"minutesToAdd": @(minutesToAdd) // }]; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver: self name: @"SCConfigurationChangedNotification" object: nil]; [[NSDistributedNotificationCenter defaultCenter] removeObserver: self name: @"SCConfigurationChangedNotification" object: nil]; } - (id)initialWindow { return initialWindow_; } - (id)domainListWindowController { return domainListWindowController_; } - (void)setDomainListWindowController:(id)newController { domainListWindowController_ = newController; } - (void)installBlock { [SCSentry addBreadcrumb: @"App running installBlock method" category:@"app"]; @autoreleasepool { self.addingBlock = true; [self refreshUserInterface]; [self.xpc installDaemon:^(NSError * _Nonnull error) { if (error != nil) { if (![SCMiscUtilities errorIsAuthCanceled: error]) { [NSApp performSelectorOnMainThread: @selector(presentError:) withObject: error waitUntilDone: YES]; } self.addingBlock = false; [self refreshUserInterface]; return; } else { [SCSentry addBreadcrumb: @"Daemon installed successfully (en route to installing block)" category:@"app"]; // helper tool installed successfully, let's prepare to start the block! // for legacy reasons, BlockDuration is in minutes, so convert it to seconds before passing it through] // sanity check duration (must be above zero) NSTimeInterval blockDurationSecs = MAX([[self->defaults_ valueForKey: @"BlockDuration"] intValue] * 60, 0); NSDate* newBlockEndDate = [NSDate dateWithTimeIntervalSinceNow: blockDurationSecs]; // we're about to launch a helper tool which will read settings, so make sure the ones on disk are valid [self->settings_ synchronizeSettings]; [self->defaults_ synchronize]; // ok, the new helper tool is installed! refresh the connection, then it's time to start the block [self.xpc refreshConnectionAndRun:^{ NSLog(@"Refreshed connection and ready to start block!"); [self.xpc startBlockWithControllingUID: getuid() blocklist: [self->defaults_ arrayForKey: @"Blocklist"] isAllowlist: [self->defaults_ boolForKey: @"BlockAsWhitelist"] endDate: newBlockEndDate blockSettings: @{ @"ClearCaches": [self->defaults_ valueForKey: @"ClearCaches"], @"AllowLocalNetworks": [self->defaults_ valueForKey: @"AllowLocalNetworks"], @"EvaluateCommonSubdomains": [self->defaults_ valueForKey: @"EvaluateCommonSubdomains"], @"IncludeLinkedDomains": [self->defaults_ valueForKey: @"IncludeLinkedDomains"], @"BlockSoundShouldPlay": [self->defaults_ valueForKey: @"BlockSoundShouldPlay"], @"BlockSound": [self->defaults_ valueForKey: @"BlockSound"], @"EnableErrorReporting": [self->defaults_ valueForKey: @"EnableErrorReporting"] } reply:^(NSError * _Nonnull error) { if (error != nil ) { if (![SCMiscUtilities errorIsAuthCanceled: error]) { [NSApp performSelectorOnMainThread: @selector(presentError:) withObject: error waitUntilDone: YES]; } } else { [SCSentry addBreadcrumb: @"Block started successfully" category:@"app"]; } // get the new settings [self->settings_ synchronizeSettingsWithCompletion:^(NSError * _Nullable error) { self.addingBlock = false; [self refreshUserInterface]; }]; }]; }]; } }]; } } - (void)updateActiveBlocklist:(NSLock*)lockToUse { if(![lockToUse tryLock]) { return; } [SCSentry addBreadcrumb: @"App running updateActiveBlocklist method" category:@"app"]; // we're about to launch a helper tool which will read settings, so make sure the ones on disk are valid [settings_ synchronizeSettings]; [defaults_ synchronize]; [self.xpc refreshConnectionAndRun:^{ NSLog(@"Refreshed connection updating active blocklist!"); [self.xpc updateBlocklist: [self->defaults_ arrayForKey: @"Blocklist"] reply:^(NSError * _Nonnull error) { [self->timerWindowController_ performSelectorOnMainThread:@selector(closeAddSheet:) withObject: self waitUntilDone: YES]; if (error != nil) { if (![SCMiscUtilities errorIsAuthCanceled: error]) { [NSApp performSelectorOnMainThread: @selector(presentError:) withObject: error waitUntilDone: YES]; } } else { [SCSentry addBreadcrumb: @"Blocklist updated successfully" category:@"app"]; } [lockToUse unlock]; }]; }]; } // it really sucks, but we can't change any values that are KVO-bound to the UI unless they're on the main thread // to make that easier, here is a helper that always does it on the main thread - (void)setDefaultsBlockDurationOnMainThread:(NSNumber*)newBlockDuration { if (![NSThread isMainThread]) { [self performSelectorOnMainThread: @selector(setDefaultsBlockDurationOnMainThread:) withObject:newBlockDuration waitUntilDone: YES]; } [defaults_ setInteger: [newBlockDuration intValue] forKey: @"BlockDuration"]; } - (void)updateBlockEndDate:(NSLock*)lockToUse minutesToAdd:(NSInteger)minutesToAdd { if(![lockToUse tryLock]) { return; } [SCSentry addBreadcrumb: @"App running updateBlockEndDate method" category:@"app"]; minutesToAdd = MAX(minutesToAdd, 0); // make sure there's no funny business with negative minutes NSDate* oldBlockEndDate = [settings_ valueForKey: @"BlockEndDate"]; NSDate* newBlockEndDate = [oldBlockEndDate dateByAddingTimeInterval: (minutesToAdd * 60)]; // we're about to launch a helper tool which will read settings, so make sure the ones on disk are valid [settings_ synchronizeSettings]; [defaults_ synchronize]; [self.xpc refreshConnectionAndRun:^{ // Before we try to extend the block, make sure the block time didn't run out (or is about to run out) in the meantime if ([SCBlockUtilities currentBlockIsExpired] || [oldBlockEndDate timeIntervalSinceNow] < 1) { // we're done, or will be by the time we get to it! so just let it expire. they can restart it. [lockToUse unlock]; return; } NSLog(@"Refreshed connection updating active block end date!"); [self.xpc updateBlockEndDate: newBlockEndDate reply:^(NSError * _Nonnull error) { [self->timerWindowController_ performSelectorOnMainThread:@selector(closeAddSheet:) withObject: self waitUntilDone: YES]; // let the timer know it needs to recalculate [self->timerWindowController_ performSelectorOnMainThread:@selector(blockEndDateUpdated) withObject: nil waitUntilDone: YES]; if (error != nil) { if (![SCMiscUtilities errorIsAuthCanceled: error]) { [NSApp performSelectorOnMainThread: @selector(presentError:) withObject: error waitUntilDone: YES]; } } else { [SCSentry addBreadcrumb: @"App extended block duration successfully" category:@"app"]; } [lockToUse unlock]; }]; }]; } - (IBAction)save:(id)sender { NSSavePanel *sp; long runResult; /* create or get the shared instance of NSSavePanel */ sp = [NSSavePanel savePanel]; sp.allowedFileTypes = @[@"selfcontrol"]; /* display the NSSavePanel */ runResult = [sp runModal]; /* if successful, save file under designated name */ if (runResult == NSModalResponseOK) { NSError* err; [SCBlockFileReaderWriter writeBlocklistToFileURL: sp.URL blockInfo: @{ @"Blocklist": [defaults_ arrayForKey: @"Blocklist"], @"BlockAsWhitelist": [defaults_ objectForKey: @"BlockAsWhitelist"] } error: &err]; if (err != nil) { NSError* displayErr = [SCErr errorWithCode: 105 subDescription: err.localizedDescription]; [SCSentry captureError: displayErr]; NSBeep(); [NSApp presentError: displayErr]; return; } else { [SCSentry addBreadcrumb: @"Saved blocklist to file" category:@"app"]; } } } - (BOOL)openSavedBlockFileAtURL:(NSURL*)fileURL { NSDictionary* settingsFromFile = [SCBlockFileReaderWriter readBlocklistFromFile: fileURL]; if (settingsFromFile != nil) { [defaults_ setObject: settingsFromFile[@"Blocklist"] forKey: @"Blocklist"]; [defaults_ setObject: settingsFromFile[@"BlockAsWhitelist"] forKey: @"BlockAsWhitelist"]; [SCSentry addBreadcrumb: @"Opened blocklist from file" category:@"app"]; } else { NSLog(@"WARNING: Could not read a valid blocklist from file - ignoring."); return NO; } // close the domain list (and reopen again if need be to refresh) BOOL domainListIsOpen = [[domainListWindowController_ window] isVisible]; NSRect frame = [[domainListWindowController_ window] frame]; [self closeDomainList]; if(domainListIsOpen) { [self showDomainList: self]; [[domainListWindowController_ window] setFrame: frame display: YES]; } [self refreshUserInterface]; return YES; } - (IBAction)open:(id)sender { NSOpenPanel* oPanel = [NSOpenPanel openPanel]; oPanel.allowedFileTypes = @[@"selfcontrol"]; oPanel.allowsMultipleSelection = NO; long result = [oPanel runModal]; if (result == NSModalResponseOK) { if([oPanel.URLs count] > 0) { [self openSavedBlockFileAtURL: oPanel.URLs[0]]; } } } - (BOOL)application:(NSApplication*)theApplication openFile:(NSString*)filename { return [self openSavedBlockFileAtURL: [NSURL fileURLWithPath: filename]]; } - (IBAction)openFAQ:(id)sender { [SCSentry addBreadcrumb: @"Opened SelfControl FAQ" category:@"app"]; NSURL *url=[NSURL URLWithString: @"https://github.com/SelfControlApp/selfcontrol/wiki/FAQ#q-selfcontrols-timer-is-at-0000-and-i-cant-start-a-new-block-and-im-freaking-out"]; [[NSWorkspace sharedWorkspace] openURL: url]; } @end