//
// 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
LKLicenseWindowController.m
This file can be downloaded as part of milnlicence.tbz.