UKSoftwareUpdater.m

This file can be downloaded as part of milnupdate.tbz.

//
//  UKSoftwareUpdater.m
//  UpdateKit
//
//  Copyright © 2018 Graham Miln. All rights reserved. https://miln.eu
//
//  This package is subject to the terms of the Artistic License 2.0.
//  If a copy of the Artistic-2.0 was not distributed with this file, you can
//  obtain one at https://indie.miln.eu/licence

@import UpdateCore;
#import "UKAvailableUpdate.h"
#import "UKSoftwareUpdater.h"
#import "UKUpdateController.h"

// Defaults
// Use `defaults delete eu.miln.update.basic-kit UKSoftwareUpdaterLastCheck` to reset the last check date in the example application
static NSString* UKSoftwareUpdaterDefaultsKeyLastCheck = @"UKSoftwareUpdaterLastCheck"; /**< Time interval since reference date of last check (double) */
static NSString* UKSoftwareUpdaterDefaultsKeyNextCheckInterval = @"UKSoftwareUpdaterNextCheckInterval"; /**< Time interval between automatic checks (double) */
// TODO: Replace defaults with caching headers from discovery; move logic to the server
static NSTimeInterval UKSoftwareUpdaterDefaultsValueNextCheckInterval = (60 * 60 * 24 * 7); /**< Seconds to wait between automatic checks; once a week */
static NSTimeInterval UKSoftwareUpdaterDefaultsValueNextCheckIntervalMinimum = 60; /**< Minimum period to wait between automatic checks; one minute. */

static NSString* UKSoftwareUpdaterInfoPlistKeyManualURL = @"UKSoftwareUpdaterManualURL"; /**< URL to send user to in case of error. */

// KVO
static NSString* UKSoftwareUpdaterKeyProgress = @"progress";

// Localised keys
static NSString* UKSoftwareUpdaterLocalisedKeyCheckForUpdatesNoneMessage = @"checkforupdates.none.message"; /* {1} application name */
static NSString* UKSoftwareUpdaterLocalisedKeyCheckForUpdatesNoneInfo = @"checkforupdates.none.info"; /* {1} application name */
static NSString* UKSoftwareUpdaterLocalisedKeyInstallErrorMessage = @"installerror.message"; /* {1} application name {2} error message */
static NSString* UKSoftwareUpdaterLocalisedKeyInstallErrorInfo = @"installerror.info"; /* {1} application name {2} error message */
static NSString* UKSoftwareUpdaterLocalisedKeyInstallErrorButtonTitleManual = @"installerror.button.title.manual";
static NSString* UKSoftwareUpdaterLocalisedKeyInstallErrorButtonTitleCancel = @"installerror.button.title.cancel";

static NSString* UKSoftwareUpdaterKVOContextProgress = @"UKSoftwareUpdaterKVOContextProgress";

static NSString* UKSoftwareUpdaterLocalizationMessageText = @"softwareavailable.message.fmt"; /* {1} application name (string) */
static NSString* UKSoftwareUpdaterLocalizationInformationText = @"softwareavailable.information.fmt"; /* {1} application name (string) */

@interface UKSoftwareUpdater ()
@property(strong) NSProgress* progress; // Required by NSProgressReporting. Bound to updateController.progress
@property(strong) UKUpdateController* updater;
@property(strong) NSTimer* automatic;
@property(strong) NSObject<UKAvailableUpdate>* availableUpdate;
// ...
@property(strong) NSString* messageText;
@property(strong) NSString* informationText;

/** Present an error to the user and offer, if available, to open a manual URL. */
- (void)presentError:(NSError*)inError forWindow:(NSWindow*)inWindow completionHandler:(void (^ _Nullable)(void))inHandler;

/** Check for updates, with optional response in the user interface. */
- (void)checkForUpdatesWithResponse:(BOOL)showResponse;

/** Interval between automatic checks for updates. */
- (NSTimeInterval)nextCheckInterval;

/** Is an update being discovered, downloaded, or installed? */
- (BOOL)isBusy;
@end

@implementation UKSoftwareUpdater

+ (void)initialize {
	// Register reasonable defaults to reduce required configuration
	[NSUserDefaults.standardUserDefaults registerDefaults:@{UKSoftwareUpdaterDefaultsKeyNextCheckInterval:@(UKSoftwareUpdaterDefaultsValueNextCheckInterval)}];
}

- (nonnull instancetype)init {
    if ((self = [super initWithWindow:nil])) {
		// updater (UKUpdateController) does the work that underpins the interface
		self.updater = [UKUpdateController new];
		[self bind:UKSoftwareUpdaterKeyProgress toObject:self.updater withKeyPath:UKSoftwareUpdaterKeyProgress options:nil];
		
		// Schedule automatic checks for updates
		NSDate* nc = self.nextCheck;
		// ...avoid checking immediately
		NSDate* soon = [NSDate dateWithTimeIntervalSinceNow:UKSoftwareUpdaterDefaultsValueNextCheckIntervalMinimum];
		self.automatic = [[NSTimer alloc] initWithFireDate:[nc laterDate:soon] interval:self.nextCheckInterval repeats:YES block:^(NSTimer* inTimer) {
			[self performSelectorOnMainThread:@selector(quietlyCheckForUpdates:) withObject:self waitUntilDone:NO];
		}];
		[NSRunLoop.mainRunLoop addTimer:self.automatic forMode:NSDefaultRunLoopMode];
	}
	return self;
}

/** Return an instance with a specific discovery URL.
 @discussion Use default constructor to read URL from Info.plist. */
- (nonnull instancetype)initWithDiscoveryURL:(nonnull NSURL*)aDiscoveryURL {
    if ((self = [self init])) {
        self.updater.discoveryURL = aDiscoveryURL;
    }
    return self;
}

- (nonnull instancetype)initWithWindow:(NSWindow *)window {
    return [self init];
}

- (instancetype)initWithCoder:(NSCoder *)coder {
    return [self init];
}

- (void)dealloc {
	[self.automatic invalidate];
	[self unbind:UKSoftwareUpdaterKeyProgress];
}

#pragma mark -

- (NSNibName)windowNibName {
	return NSStringFromClass(self.class);
}

- (void)windowWillLoad {
	[super windowWillLoad];
	
	// Localised text
	NSBundle* thisBundle = [NSBundle bundleForClass:self.class];
	NSString* thisClass = NSStringFromClass(self.class);
	NSString* applicationName = [[NSFileManager new] displayNameAtPath:NSBundle.mainBundle.bundlePath];
	NSString* msgFmt = NSLocalizedStringFromTableInBundle(UKSoftwareUpdaterLocalizationMessageText, thisClass, thisBundle, @"Software update available.");
	self.messageText = [NSString stringWithFormat:msgFmt,applicationName];
	NSString* infoFmt = NSLocalizedStringFromTableInBundle(UKSoftwareUpdaterLocalizationInformationText, thisClass, thisBundle, @"App would like to download and install an update.");
	self.informationText = [NSString stringWithFormat:infoFmt,applicationName];
}

#pragma mark -

+ (NSSet<NSString *> *)keyPathsForValuesAffectingNextCheck {
	return [NSSet setWithObjects:@"lastCheck", nil];
}

- (NSDate*)lastCheck {
	NSTimeInterval lastCheckTime = [NSUserDefaults.standardUserDefaults doubleForKey:UKSoftwareUpdaterDefaultsKeyLastCheck];
	NSDate* lastCheckDate = nil;
	if (lastCheckTime != 0) {
		lastCheckDate = [NSDate dateWithTimeIntervalSinceReferenceDate:lastCheckTime];
	}
	return lastCheckDate;
}

- (void)setLastCheck:(NSDate*)lastCheck {
	[NSUserDefaults.standardUserDefaults setDouble:lastCheck.timeIntervalSinceReferenceDate forKey:UKSoftwareUpdaterDefaultsKeyLastCheck];
}

- (NSTimeInterval)nextCheckInterval {
	NSTimeInterval interval = [NSUserDefaults.standardUserDefaults doubleForKey:UKSoftwareUpdaterDefaultsKeyNextCheckInterval];
	if (interval < UKSoftwareUpdaterDefaultsValueNextCheckIntervalMinimum) {
		interval = UKSoftwareUpdaterDefaultsValueNextCheckInterval;
	}
	return interval;
}

- (NSDate*)nextCheck {
	NSDate* lc = self.lastCheck;
	if (lc != nil) {
		return [lc dateByAddingTimeInterval:self.nextCheckInterval];
	} else {
		return [NSDate new];
	}
}

- (BOOL)isBusy {
	// Simplistic interpretation of busy; if anything is in progress, it is busy
	return (self.progress != nil);
}

- (IBAction)checkForUpdates:(id)aSender {
	[self checkForUpdatesWithResponse:YES];
}

- (IBAction)quietlyCheckForUpdates:(id)aSender {
	if (self.delegate != nil) {
		if ([self.delegate canShowUpdatesFrom:self] == NO) {
			// Nothing to do
			NSLog(@"[INFO] Quiet software update check disabled by delegate.");
			return;
		}
	}
	[self checkForUpdatesWithResponse:NO];
}

- (void)checkForUpdatesWithResponse:(BOOL)showResponse {
	if (self.isBusy == YES) {
		//[self showWindow:aSender]; // TODO: discovery window state
		// Nothing to do, already busy
		return;
	}
	
	// Progress is bound; can ignore returned progress
	(void) [self.updater discoverWithCompletionHandler:^(NSObject<UKAvailableUpdate>* _Nullable inUpdate, NSError * _Nullable inDiscoveryError) {
		self.availableUpdate = inUpdate;
		
		// Note last check date, regardless of an error
		[self setLastCheck:[NSDate new]];
		
		if (inDiscoveryError != nil) {
			NSLog(@"[ERROR] %@", inDiscoveryError);
			
			if (showResponse == YES) {
				[self presentError:inDiscoveryError forWindow:nil completionHandler:nil];
			}
			
			return;
		}
		
		if (inUpdate == nil) {
			if (showResponse == YES) {
				// No update available
				NSAlert* latestAlert = [NSAlert new];
				latestAlert.icon = [NSImage imageNamed:NSImageNameApplicationIcon];
				// ...localise
				NSBundle* thisBundle = [NSBundle bundleForClass:self.class];
				NSString* thisClass = NSStringFromClass(self.class);
				NSString* applicationName = [[NSFileManager new] displayNameAtPath:NSBundle.mainBundle.bundlePath];
				NSString* msgFmt = NSLocalizedStringFromTableInBundle(UKSoftwareUpdaterLocalisedKeyCheckForUpdatesNoneMessage, thisClass, thisBundle, @"App is up to date.");
				latestAlert.messageText = [NSString stringWithFormat:msgFmt,applicationName];
				NSString* infoFmt = NSLocalizedStringFromTableInBundle(UKSoftwareUpdaterLocalisedKeyCheckForUpdatesNoneInfo, thisClass, thisBundle, @"There are no updates available.");
				latestAlert.informativeText = [NSString stringWithFormat:infoFmt,applicationName];
				// ...block entire application to say nothing is available. Not a great experience but simplest that works.
				(void) [latestAlert runModal];
			}
		} else {
			NSLog(@"[%@] Update found: %@", NSStringFromClass(self.class), inUpdate);
			
			// An update is available
			[self showWindow:self];
		}
	}];
}

- (IBAction)install:(id)aSender	{
	// Progress is bound; can ignore returned progress
	(void) [self.availableUpdate installWithCompletionHandler:^(NSError* _Nullable inInstallError) {
		NSLog(@"[%@] Install finished: %@", NSStringFromClass(self.class), inInstallError);
		self.progress = nil;
		
		if (inInstallError == nil) {
			// Installation was successful; installer should relaunch or present success interface
			[self close];
		} else {
			[self presentError:inInstallError forWindow:self.window completionHandler:^{
				// Close the software update window
				[self close];
			}];
		}
	}];
}

- (IBAction)installManually:(id)aSender {
    // Progress is bound; can ignore returned progress
    (void) [self.availableUpdate installManuallyWithCompletionHandler:^(NSError* _Nullable inInstallError) {
        NSLog(@"[%@] Manual install finished: %@", NSStringFromClass(self.class), inInstallError);
        self.progress = nil;
        
        if (inInstallError == nil) {
            // Installation was successful; installer should relaunch or present success interface
            [self close];
        } else {
			[self presentError:inInstallError forWindow:self.window completionHandler:^{
				// Close the software update window
				[self close];
			}];
        }
    }];
}

- (IBAction)cancel:(id)aSender {
	[self.progress cancel];
}

- (void)presentError:(NSError*)inError forWindow:(NSWindow*)inWindow completionHandler:(void (^ _Nullable)(void))inHandler {
	// TODO: Ideally use presentError.
	NSAlert* errorAlert = [NSAlert new];
	// ...localise
	NSBundle* thisBundle = [NSBundle bundleForClass:self.class];
	NSString* thisClass = NSStringFromClass(self.class);
	NSString* applicationName = [[NSFileManager new] displayNameAtPath:NSBundle.mainBundle.bundlePath];
	NSString* msgFmt = NSLocalizedStringFromTableInBundle(UKSoftwareUpdaterLocalisedKeyInstallErrorMessage, thisClass, thisBundle, @"Installation failed.");
	errorAlert.messageText = [NSString stringWithFormat:msgFmt,applicationName,inError.localizedDescription];
	NSString* infoFmt = NSLocalizedStringFromTableInBundle(UKSoftwareUpdaterLocalisedKeyInstallErrorInfo, thisClass, thisBundle, @"Unable to install the new software.");
	errorAlert.informativeText = [NSString stringWithFormat:infoFmt,applicationName,inError.localizedDescription];
	
	NSString* errorURLString = [NSBundle.mainBundle objectForInfoDictionaryKey:UKSoftwareUpdaterInfoPlistKeyManualURL];
	NSURL* errorURL = nil;
	if ([errorURLString isKindOfClass:NSString.class] == YES) {
		errorURL = [NSURL URLWithString:errorURLString];
		if (errorURL != nil) {
			NSString* manualTitle = NSLocalizedStringFromTableInBundle(UKSoftwareUpdaterLocalisedKeyInstallErrorButtonTitleManual, thisClass, thisBundle, @"Retry manually.");
			(void) [errorAlert addButtonWithTitle:manualTitle];
			
			NSString* cancelTitle = NSLocalizedStringFromTableInBundle(UKSoftwareUpdaterLocalisedKeyInstallErrorButtonTitleCancel, thisClass, thisBundle, @"Cancel.");
			(void) [errorAlert addButtonWithTitle:cancelTitle];
		} else {
			NSLog(@"[%@] Invalid URL for Info.plist key %@: %@", NSStringFromClass(self.class), UKSoftwareUpdaterInfoPlistKeyManualURL, errorURLString);
		}
	}
	
	typedef void (^UKSUResponseHandler)(NSModalResponse inReturnCode);
	UKSUResponseHandler rh = ^(NSModalResponse inReturnCode) {
		if (inReturnCode == NSAlertFirstButtonReturn && errorURL != nil) {
			bool validOpen = [NSWorkspace.sharedWorkspace openURL:errorURL];
			if (validOpen != YES) {
				NSLog(@"[%@] Open URL failed: %@", NSStringFromClass(self.class), errorURL.absoluteString);
			}
		}
		
		if (inHandler != nil) {
			inHandler();
		}
	};
	
	if (inWindow != nil) {
		[errorAlert beginSheetModalForWindow:inWindow completionHandler:^(NSModalResponse inReturnCode) {
			rh(inReturnCode);
		}];
	} else {
		rh([errorAlert runModal]);
	}
}

- (BOOL)validateMenuItem:(NSMenuItem *)inMenuItem {
	return [self validateUserInterfaceItem:inMenuItem];
}

- (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)inItem {
	if ((inItem.action == @selector(checkForUpdates:)) ||
		([inItem action] == @selector(install:)) ||
		([inItem action] == @selector(cancel:))) {
		return !self.isBusy;
	}
	return NO;
}

@end