LKLicenseWindowController.m

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

//
//  LKLicenseWindowController.m
//  LicenceKit - https://indie.miln.eu
//
//  Copyright © 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 LicenceCore;
#import <SecurityInterface/SFCertificatePanel.h>
#import "LKLicenseWindowController.h"
#import "LKKeychainAssistant.h"

// Info.plist keys
NSString* LKLicenceKitInfoPListKeyTrusted = @"LKTrusted";
NSString* LKLicenceKitInfoPListKeyStoreURL = @"LKStoreURL";
NSString* LKLicenceKitInfoPListKeyFileTypes = @"LKFileTypes";

// Useful UTI types
NSString* LKLicenceKitUTIX509Certificate = @"public.x509-certificate";
NSString* LKLicenceKitPEMFileExtension = @"pem";

// Localisable errors
static NSString* LKLicenceWindowControllerErrorMissingNamedCertificate = @"error.named-certificate.missing.fmt"; // {1} name string
static NSString* LKLicenceWindowControllerErrorInvalidCertificateNames = @"error.named-certificates.invalid.fmt"; // {1} Info.plist key {2} unknown non-string entry
static NSString* LKLicenceWindowControllerErrorInvalidNamedCertificate = @"error.named-certificate.invalid.fmt"; // {1} Info.plist key {2} unknown non-string entry

// Localisable string
static NSString* LKLicenceWindowControllerLocalizedAddLicenceTitle = @"addlicence.title";
static NSString* LKLicenceWindowControllerLocalizedAddLicencePrompt = @"addlicence.prompt";
static NSString* LKLicenceWindowControllerLocalizedAddLicenceMessage = @"addlicence.message";
// ...
static NSString* LKLicenceWindowControllerLocalizedRemoveLicencesMessage = @"removelicences.message";
static NSString* LKLicenceWindowControllerLocalizedRemoveLicencesInformativeFormat = @"removelicences.informative.fmt"; // {1} application name
static NSString* LKLicenceWindowControllerLocalizedRemoveLicencesButtonRemove = @"removelicences.button.title.remove";
static NSString* LKLicenceWindowControllerLocalizedRemoveLicencesButtonCancel = @"removelicences.button.title.cancel";

// Defaults
static NSString* LKLicenceWindowControllerDefaultKeyTrustedLicenceID = @"eu.miln.licence.luid";

@interface LKLicenseWindowController () <NSTableViewDelegate, NSTableViewDataSource, NSFilePromiseProviderDelegate, LCLicenceObserverProtocol>
@property(weak) IBOutlet NSTableView* licenceTable;
@property(strong,readwrite) LCLicenceCore* core; /** Shared licence assistant */
@property(strong) LCLicenceObserver* observer;
@property(strong) LKKeychainAssistant* keychain;

- (NSArray<NSData *> *)namedCertificatesInBundleInfo:(NSString*)inInfoProperty error:(NSError *__autoreleasing __nonnull *)outError;

/** Add multiple licences from file URLs. */
- (void)addLicencesWithURLs:(NSArray<NSURL*>*)someFileURLs;

/** Add a licence from a file URL. */
- (NSError*)addLicenceWithURL:(NSURL*)aFileURL;

/** Respond to action on specific licence row. */
- (IBAction)licenceTableDoubleAction:(id)aSender;
@end

@implementation LKLicenseWindowController

+ (LKLicenseWindowController*)sharedLicenseWindow {
	static LKLicenseWindowController* sharedWindow = nil;
	static dispatch_once_t onceToken;
	dispatch_once(&onceToken, ^{
		sharedWindow = [LKLicenseWindowController new];
	});
	return sharedWindow;
}

- (instancetype)init {
	if ((self = [super initWithWindow:nil])) {
		self.core = [LCLicenceCore sharedCore];
		
		if ([[NSBundle mainBundle] objectForInfoDictionaryKey:LKLicenceKitInfoPListKeyTrusted] != nil) {
			// Provide licence core with trusted anchors
			NSError* resourceError = nil;
			NSArray<NSData*>* pems = [self namedCertificatesInBundleInfo:LKLicenceKitInfoPListKeyTrusted error:&resourceError];
			if (resourceError != nil) {
				NSLog(@"[ERROR] Anchor Certificates: %@",resourceError);
			}
			if (pems != nil) {
				NSError* trustError = [self.core setTrustedAnchors:pems];
				if (trustError != nil) {
					NSLog(@"[ERROR] Malformed Anchor Certificates: %@",trustError);
				}
			}
		}
		
		// TODO: could make keychain support optional
		
		// KeychainAssistant observes licence core and deals with licence persistence between launches
		self.keychain = [[LKKeychainAssistant alloc] initWithCore:self.core];
		// ...add licences from the Keychain; licences will start being evaluated asynchronisely
		[self.keychain addLicences];
		// ...assured of not being licenced immediately, so does a licence exist that was previously trusted?
		NSArray<NSString*>* licenceIDs = [NSUserDefaults.standardUserDefaults stringArrayForKey:LKLicenceWindowControllerDefaultKeyTrustedLicenceID];
		NSUInteger i = [self.core.licences indexOfObjectWithOptions:NSEnumerationConcurrent passingTest:^BOOL(LCLicence* inLicence,NSUInteger __unused inIndex,BOOL* __unused outShouldStop) {
			return [licenceIDs containsObject:inLicence.uid];
		}];
		// ...ask licence core to trust pending licences for the moment; this avoids an initial 'no licence' period for licensed users
		self.core.trustPending = (i != NSNotFound);
		// ...observe after setting up trustPending
		self.observer = [LCLicenceObserver observerWithCore:self.core delegate:self];
	}
	return self;
}

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

- (void)windowDidLoad {
	[self.licenceTable setDraggingSourceOperationMask:NSDragOperationCopy forLocal:NO];
	[self.licenceTable registerForDraggedTypes:@[NSFilenamesPboardType]]; // NSPasteboardTypeFileURL exists on macOS 10.13+
	self.licenceTable.doubleAction = @selector(licenceTableDoubleAction:);
	[super windowDidLoad];
}

- (void)showWindow:(id)aSender {
	[super showWindow:aSender];
}

- (IBAction)addLicence:(id)aSender {
	NSOpenPanel* openPanel = [NSOpenPanel openPanel];
	openPanel.allowedFileTypes = [self allowedFileTypes];
	openPanel.allowsMultipleSelection = YES;
	NSString* thisClass = NSStringFromClass(self.class);
	NSBundle* thisBundle = [NSBundle bundleForClass:self.class];
	openPanel.title = NSLocalizedStringFromTableInBundle(LKLicenceWindowControllerLocalizedAddLicenceTitle, thisClass, thisBundle, @"Open panel prompt");
	openPanel.prompt = NSLocalizedStringFromTableInBundle(LKLicenceWindowControllerLocalizedAddLicencePrompt, thisClass, thisBundle, @"Open panel title");;
	openPanel.message = NSLocalizedStringFromTableInBundle(LKLicenceWindowControllerLocalizedAddLicenceMessage, thisClass, thisBundle, @"Open panel message");;
	[openPanel beginSheetModalForWindow:self.window completionHandler:^(NSModalResponse inResult) {
		if (inResult == NSModalResponseOK) {
			[self addLicencesWithURLs:openPanel.URLs];
		}
	}];
}

- (IBAction)removeLicences:(id)aSender {
	NSAlert* confirm = [NSAlert new];
	NSString* thisClass = NSStringFromClass(self.class);
	NSBundle* thisBundle = [NSBundle bundleForClass:self.class];
	confirm.messageText = NSLocalizedStringFromTableInBundle(LKLicenceWindowControllerLocalizedRemoveLicencesMessage, thisClass, thisBundle, @"Remove licences message");
	NSString* infoFmt = NSLocalizedStringFromTableInBundle(LKLicenceWindowControllerLocalizedRemoveLicencesInformativeFormat, thisClass, thisBundle, @"Remove licences info");
	NSString* applicationName = [[NSFileManager defaultManager] displayNameAtPath:[[NSBundle mainBundle] bundlePath]];
	confirm.informativeText = [NSString stringWithFormat:infoFmt,applicationName];
	(void) [confirm addButtonWithTitle:NSLocalizedStringFromTableInBundle(LKLicenceWindowControllerLocalizedRemoveLicencesButtonRemove, thisClass, thisBundle, @"Remove button: remove")];
	(void) [confirm addButtonWithTitle:NSLocalizedStringFromTableInBundle(LKLicenceWindowControllerLocalizedRemoveLicencesButtonCancel, thisClass, thisBundle, @"Remove button: cancel")];
	[confirm beginSheetModalForWindow:self.window completionHandler:^(NSModalResponse inResult) {
		if (inResult == NSAlertFirstButtonReturn) {
			[self.keychain removeLicencesFromKeychain];
			[self.core removeAll];
		}
	}];
}

- (IBAction)buyLicence:(id)aSender {
	NSURL* url = self.storeURL;
	if (url != nil) {
		(void) [[NSWorkspace sharedWorkspace] openURL:self.storeURL];
	}
}

#pragma mark -

- (NSURL *)storeURL {
	NSObject* storeStr = [[NSBundle mainBundle] objectForInfoDictionaryKey:LKLicenceKitInfoPListKeyStoreURL];
	if ([storeStr isKindOfClass:NSString.class]) {
		return [NSURL URLWithString:(NSString*)storeStr];
	} else {
		return nil;
	}
}

- (NSArray<NSString*>*)allowedFileTypes {
	NSArray<NSString*>* optionalFileTypes = [[NSBundle mainBundle] objectForInfoDictionaryKey:LKLicenceKitInfoPListKeyFileTypes];
	if ([optionalFileTypes isKindOfClass:NSArray.class]) {
		return optionalFileTypes;
	} else {
		// Common Privacy Enhanced Mail (PEM) encoded certificate extensions
		return @[LKLicenceKitUTIX509Certificate, LKLicenceKitPEMFileExtension, @"cert", @"cer", @"crt"];
	}
}

#pragma mark -

- (NSArray<NSData *> *)namedCertificatesInBundleInfo:(NSString*)inInfoProperty error:(NSError *__autoreleasing __nonnull *)outError {
	NSMutableArray<NSData*>* pems = nil;
	NSBundle* thisBundle = [NSBundle bundleForClass:self.class];
	NSString* thisClass = NSStringFromClass(self.class);
	
	NSObject* potentialNames = [[NSBundle mainBundle] objectForInfoDictionaryKey:inInfoProperty];
	if ([potentialNames isKindOfClass:NSArray.class]) {
		pems = [NSMutableArray new];
		for (NSObject* potentialName in (NSArray*)potentialNames) {
			if ([potentialName isKindOfClass:NSString.class]) {
				NSString* name = (NSString*)potentialName;
				NSURL* url = [[NSBundle mainBundle] URLForResource:name withExtension:LKLicenceKitPEMFileExtension];
				if (url == nil) {
					// Missing named resource
					if (outError != NULL) {
						NSString* format = NSLocalizedStringFromTableInBundle(LKLicenceWindowControllerErrorMissingNamedCertificate, thisClass, thisBundle, @"Missing named certificate.");
						*outError = [[NSError alloc] initWithDomain:NSOSStatusErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey:[NSString stringWithFormat:format,name]}];
					}
					return nil;
				}
				NSData* pemData = [NSData dataWithContentsOfURL:url options:NSDataReadingUncached error:outError];
				if (pemData == nil) {
					// Unable to read resource
					return nil;
				}
				[pems addObject:pemData];
			} else {
				// Invalid Info.plist entry; expected an array of strings
				if (outError != NULL) {
					NSString* format = NSLocalizedStringFromTableInBundle(LKLicenceWindowControllerErrorInvalidNamedCertificate, thisClass, thisBundle, @"Invalid Info.plist item %@: %@.");
					*outError = [[NSError alloc] initWithDomain:NSOSStatusErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey:[NSString stringWithFormat:format,inInfoProperty,potentialName]}];
				}
				return nil;
			}
		}
	} else {
		// Invalid Info.plist entry; expected an array
		if (outError != NULL) {
			NSString* format = NSLocalizedStringFromTableInBundle(LKLicenceWindowControllerErrorInvalidCertificateNames, thisClass, thisBundle, @"Invalid Info.plist item %@: %@.");
			*outError = [[NSError alloc] initWithDomain:NSOSStatusErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey:[NSString stringWithFormat:format,inInfoProperty,potentialNames]}];
		}
	}
	return pems;
}

#pragma mark -

- (void)addLicencesWithURLs:(NSArray<NSURL *> *)someFileURLs {
	for(NSURL* url in someFileURLs) {
		NSError* addError = [self addLicenceWithURL:url];
		if (addError != nil) {
			[self presentError:addError modalForWindow:self.window delegate:nil didPresentSelector:nil contextInfo:nil];
			break;
		}
	}
}

- (NSError*)addLicenceWithURL:(NSURL*)aFileURL {
	NSParameterAssert(aFileURL);
	NSError* error = nil;
	NSData* licenceData = [NSData dataWithContentsOfURL:aFileURL options:NSDataReadingUncached error:&error];
	if (error == nil) {
		[self.core addLicenceWithData:licenceData format:kSecFormatPEMSequence];
	}
	return error;
}

- (IBAction)licenceTableDoubleAction:(id)aSender {
	NSInteger row = self.licenceTable.clickedRow;
	if (row >= 0 && self.core.licences.count > row) {
		if (NSClassFromString(@"SFCertificatePanel") != nil) {
			NSUInteger modifiers = ([NSEvent modifierFlags] & NSEventModifierFlagDeviceIndependentFlagsMask);
			BOOL showGroup = (modifiers == NSEventModifierFlagOption);
			[[SFCertificatePanel sharedCertificatePanel] beginSheetForWindow:self.window modalDelegate:nil didEndSelector:nil contextInfo:nil trust:self.core.licences[row].trust showGroup:showGroup];
		}
	}
}

#pragma mark - LicenceObserver

- (void)licenceObserver:(LCLicenceObserver *)anObserver licensed:(BOOL)isLicensed {
	if (isLicensed) {
		// Note trusted licence identifiers for next launch
		NSMutableArray<NSString*>* licenceIDs = [NSMutableArray new];
		[self.core.licences enumerateObjectsUsingBlock:^(LCLicence* inLicence,NSUInteger __unused inIndex,BOOL* __unused outShouldStop) {
			if (inLicence.isTrusted) {
				[licenceIDs addObject:inLicence.uid];
			}
		}];
		// Avoid removing previously trusted but potentially pending
		if (licenceIDs.count > 0) {
			[NSUserDefaults.standardUserDefaults setObject:licenceIDs forKey:LKLicenceWindowControllerDefaultKeyTrustedLicenceID];
		}
	}
}

- (void)licenceObserver:(LCLicenceObserver *)anObserver didChange:(LCLicence *)aLicence {
	[self.licenceTable reloadData];
}

#pragma mark - NSTableViewDataSource and Delegate

- (NSView*)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn*)tableColumn row:(NSInteger)row {
	NSView* view = nil;
	LCLicence* licence = [self tableView:tableView objectValueForTableColumn:tableColumn row:row];
	if (licence != nil) {
		NSUserInterfaceItemIdentifier viewType = @"pending";
		if (licence.isPending) {
			// defaults to pending
		} else if (licence.isTrusted) {
			viewType = @"trusted";
		} else {
			viewType = @"untrusted";
		}
		view = [tableView makeViewWithIdentifier:viewType owner:self];
	}
	return view;
}

- (id)tableView:(NSTableView *) __unused tableView objectValueForTableColumn:(NSTableColumn *) __unused tableColumn row:(NSInteger)row {
	if (row >= 0 && self.core.licences.count > row) {
		return self.core.licences[row];
	} else {
		return nil;
	}
}

- (id<NSPasteboardWriting>)tableView:(NSTableView *) __unused tableView pasteboardWriterForRow:(NSInteger)row {
	if (row >= 0 && self.core.licences.count > row) {
		if (@available(macOS 10.12, *)) {
			NSFilePromiseProvider* filePromise = [[NSFilePromiseProvider alloc] initWithFileType:LKLicenceKitUTIX509Certificate delegate:self];filePromise.userInfo = self.core.licences[row].serialNumber;
			return filePromise;
		} else {
			// Fallback on earlier versions
			return nil;
		}
		
	} else {
		return nil;
	}
}

- (NSDragOperation)tableView:(NSTableView *)tableView validateDrop:(id <NSDraggingInfo>)info proposedRow:(NSInteger) __unused row proposedDropOperation:(NSTableViewDropOperation) __unused dropOperation {
	NSDragOperation op = NSDragOperationNone;
	bool validDrop = NO;
	NSPasteboard* pb = info.draggingPasteboard;
	for (NSString* t in pb.types) {
		// Ideally perform more involved UTI equivalence for comparison
		if ([t isEqualToString:NSFilenamesPboardType]) {
			// Check file extension
			NSUInteger i = [[pb propertyListForType:t] indexOfObjectPassingTest:^BOOL(NSString* inFilename, NSUInteger __unused inIndex,BOOL* __unused outShouldStop) {
				return [self.allowedFileTypes containsObject:[inFilename pathExtension]];
			}];
			if (i != NSNotFound) {
				validDrop = YES;
				break;
			}
		}
	}
	if (validDrop) {
		op = NSDragOperationCopy;
		[tableView setDropRow:-1 dropOperation:NSTableViewDropOn];
	}
	return op;
}

- (BOOL)tableView:(NSTableView *)tableView acceptDrop:(id<NSDraggingInfo>)info row:(NSInteger)row dropOperation:(NSTableViewDropOperation)dropOperation {
	BOOL validDrop = YES;
	NSPasteboard* pb = info.draggingPasteboard;
	NSArray* filenames = [pb propertyListForType:NSFilenamesPboardType];
	for (NSString* filename in filenames) {
		NSURL* fileURL = [NSURL fileURLWithPath:filename];
		if (fileURL) {
			NSError* error = [self addLicenceWithURL:fileURL];
			if (error != nil) {
				validDrop = NO;
				break;
			}
		}
	}
	return validDrop;
}

#pragma mark - NSFilePromiseProviderDelegate

- (NSString *)filePromiseProvider:(NSFilePromiseProvider*)filePromiseProvider fileNameForType:(NSString *)fileType  API_AVAILABLE(macos(10.12)){
	NSString* serialNumber = filePromiseProvider.userInfo;
	return [serialNumber stringByAppendingPathExtension:LKLicenceKitPEMFileExtension];
}

- (void)filePromiseProvider:(NSFilePromiseProvider*)filePromiseProvider writePromiseToURL:(NSURL *)url completionHandler:(void (^)(NSError * __nullable errorOrNil))completionHandler  API_AVAILABLE(macos(10.12)){
	NSString* serialNumber = filePromiseProvider.userInfo;
	NSUInteger i = [self.core.licences indexOfObjectPassingTest:^BOOL(LCLicence* inLicence, NSUInteger __unused inIndex, BOOL* __unused outShouldStop) {
		return ([inLicence.serialNumber isEqualToString:serialNumber]);
	}];
	NSError* error = nil;
	if (i != NSNotFound) {
		LCLicence* licence = self.core.licences[i];
		[[licence exportAsPEMWithError:&error] writeToURL:url options:0 error:&error];
	} else {
		error = [NSError errorWithDomain:NSOSStatusErrorDomain code:errSecParam userInfo:nil];
	}
	completionHandler(error);
}

@end