hammerspoon/Pods/MIKMIDI/Source/MIKMIDIConnectionManager.m

573 lines
21 KiB
Objective-C

//
// MIKMIDIConnectionManager.m
// MIKMIDI
//
// Created by Andrew Madsen on 11/5/15.
// Copyright © 2015 Mixed In Key. All rights reserved.
//
#import "MIKMIDIConnectionManager.h"
#import "MIKMIDIDeviceManager.h"
#import "MIKMIDIDevice.h"
#import "MIKMIDIEntity.h"
#import "MIKMIDINoteOnCommand.h"
#import "MIKMIDINoteOffCommand.h"
#if TARGET_OS_IPHONE
#import <UIKit/UIApplication.h>
#else
#import <AppKit/NSApplication.h>
#endif
void *MIKMIDIConnectionManagerKVOContext = &MIKMIDIConnectionManagerKVOContext;
NSString * const MIKMIDIConnectionManagerConnectedDevicesKey = @"MIKMIDIConnectionManagerConnectedDevicesKey";
NSString * const MIKMIDIConnectionManagerUnconnectedDevicesKey = @"MIKMIDIConnectionManagerUnconnectedDevicesKey";
BOOL MIKMIDINoteOffCommandCorrespondsWithNoteOnCommand(MIKMIDINoteOffCommand *noteOff, MIKMIDINoteOnCommand *noteOn);
@interface MIKMIDIConnectionManager ()
@property (nonatomic, strong, readwrite) MIKArrayOf(MIKMIDIDevice *) *availableDevices;
@property (nonatomic, strong, readonly) MIKMutableSetOf(MIKMIDIDevice *) *internalConnectedDevices;
@property (nonatomic, strong, readonly) MIKMapTableOf(MIKMIDIDevice *, id) *connectionTokensByDevice;
@property (nonatomic, strong) MIKMapTableOf(MIKMIDIDevice *, NSMutableArray *) *pendingNoteOnsByDevice;
@property (nonatomic, readonly) MIKMIDIDeviceManager *deviceManager;
@end
@implementation MIKMIDIConnectionManager
- (instancetype)init
{
[NSException raise:NSInternalInconsistencyException format:@"-initWithName: is the designated initializer for %@", NSStringFromClass([self class])];
return nil;
}
- (instancetype)initWithName:(NSString *)name delegate:(id<MIKMIDIConnectionManagerDelegate>)delegate eventHandler:(MIKMIDIEventHandlerBlock)eventHandler
{
self = [super init];
if (self) {
_name = [name copy];
_delegate = delegate;
_eventHandler = eventHandler;
_automaticallySavesConfiguration = YES;
_includesVirtualDevices = YES;
_internalConnectedDevices = [[NSMutableSet alloc] init];
_connectionTokensByDevice = [NSMapTable mapTableWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsStrongMemory];
_pendingNoteOnsByDevice = [NSMapTable mapTableWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsStrongMemory];
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[self.deviceManager addObserver:self forKeyPath:@"availableDevices" options:options context:MIKMIDIConnectionManagerKVOContext];
[self.deviceManager addObserver:self forKeyPath:@"virtualSources" options:options context:MIKMIDIConnectionManagerKVOContext];
[self.deviceManager addObserver:self forKeyPath:@"virtualDestinations" options:options context:MIKMIDIConnectionManagerKVOContext];
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
[nc addObserver:self selector:@selector(deviceWasPluggedIn:) name:MIKMIDIDeviceWasAddedNotification object:nil];
[nc addObserver:self selector:@selector(deviceWasUnplugged:) name:MIKMIDIDeviceWasRemovedNotification object:nil];
[nc addObserver:self selector:@selector(endpointWasPluggedIn:) name:MIKMIDIVirtualEndpointWasAddedNotification object:nil];
[nc addObserver:self selector:@selector(endpointWasUnplugged:) name:MIKMIDIVirtualEndpointWasRemovedNotification object:nil];
#if TARGET_OS_IPHONE
[nc addObserver:self selector:@selector(saveConfigurationOnApplicationLifecycleEvent:) name:UIApplicationDidEnterBackgroundNotification object:nil];
[nc addObserver:self selector:@selector(saveConfigurationOnApplicationLifecycleEvent:) name:UIApplicationWillTerminateNotification object:nil];
#else
[nc addObserver:self selector:@selector(saveConfigurationOnApplicationLifecycleEvent:) name:NSApplicationWillTerminateNotification object:nil];
#endif
[self updateAvailableDevices];
[self scanAndConnectToInitialAvailableDevices];
}
return self;
}
- (instancetype)initWithName:(NSString *)name
{
return [self initWithName:name delegate:nil eventHandler:nil];
}
- (void)dealloc
{
__strong typeof(_delegate) delegate = self.delegate;
for (MIKMIDIDevice *device in self.connectionTokensByDevice) {
id token = [self.connectionTokensByDevice objectForKey:device];
[self.deviceManager disconnectConnectionForToken:token];
if ([delegate respondsToSelector:@selector(connectionManager:deviceWasDisconnected:withUnterminatedNoteOnCommands:)]) {
NSArray *pendingNoteOns = [self pendingNoteOnCommandsForDevice:device];
[delegate connectionManager:self deviceWasDisconnected:device withUnterminatedNoteOnCommands:pendingNoteOns];
}
}
[self.deviceManager removeObserver:self forKeyPath:@"availableDevices" context:MIKMIDIConnectionManagerKVOContext];
[self.deviceManager removeObserver:self forKeyPath:@"virtualSources" context:MIKMIDIConnectionManagerKVOContext];
[self.deviceManager removeObserver:self forKeyPath:@"virtualDestinations" context:MIKMIDIConnectionManagerKVOContext];
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Public
#pragma mark Device Connection / Disconnection
- (BOOL)connectToDevice:(MIKMIDIDevice *)device error:(NSError **)error
{
BOOL result = [self internalConnectToDevice:device error:error];
if (self.automaticallySavesConfiguration) [self saveConfiguration];
return result;
}
- (void)disconnectFromDevice:(MIKMIDIDevice *)device
{
[self internalDisconnectFromDevice:device];
if (self.automaticallySavesConfiguration) [self saveConfiguration];
}
- (BOOL)isConnectedToDevice:(MIKMIDIDevice *)device;
{
return [self.connectedDevices containsObject:device];
}
#pragma mark Configuration Persistence
- (void)saveConfiguration
{
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
NSMutableDictionary *configuration = [NSMutableDictionary dictionaryWithDictionary:[self savedConfiguration]];
// Save connected device names
NSMutableArray *connectedDeviceNames = [configuration[MIKMIDIConnectionManagerConnectedDevicesKey] mutableCopy];
if (!connectedDeviceNames) {
connectedDeviceNames = [NSMutableArray array];
configuration[MIKMIDIConnectionManagerConnectedDevicesKey] = connectedDeviceNames;
}
// And explicitly unconnected device names
NSMutableArray *unconnectedDeviceNames = [configuration[MIKMIDIConnectionManagerUnconnectedDevicesKey] mutableCopy];
if (!unconnectedDeviceNames) {
unconnectedDeviceNames = [NSMutableArray array];
configuration[MIKMIDIConnectionManagerUnconnectedDevicesKey] = unconnectedDeviceNames;
}
// For devices that were connected in saved configuration but are now unavailable, leave them
// connected in the configuration so they'll reconnect automatically.
for (MIKMIDIDevice *device in self.availableDevices) {
NSString *name = device.name;
if (![name length]) continue;
if ([self isConnectedToDevice:device]) {
if (![connectedDeviceNames containsObject:name]) { [connectedDeviceNames addObject:name]; }
[unconnectedDeviceNames removeObject:name];
} else {
[connectedDeviceNames removeObject:name];
if (![unconnectedDeviceNames containsObject:name]) { [unconnectedDeviceNames addObject:name]; }
}
}
configuration[MIKMIDIConnectionManagerConnectedDevicesKey] = connectedDeviceNames;
configuration[MIKMIDIConnectionManagerUnconnectedDevicesKey] = unconnectedDeviceNames;
[userDefaults setObject:configuration forKey:[self userDefaultsConfigurationKey]];
}
- (void)loadConfiguration
{
for (MIKMIDIDevice *device in self.availableDevices) {
if ([self deviceIsUnconnectedInSavedConfiguration:device]) {
[self internalDisconnectFromDevice:device];
} else if ([self deviceIsConnectedInSavedConfiguration:device]) {
NSError *error = nil;
if (![self internalConnectToDevice:device error:&error]) {
NSLog(@"Unable to connect to MIDI device %@: %@", device, error);
return;
}
}
}
}
#pragma mark - Private
- (void)updateAvailableDevices
{
NSArray *regularDevices = self.deviceManager.availableDevices;
NSMutableArray *result = [NSMutableArray arrayWithArray:regularDevices];
if (self.includesVirtualDevices) {
NSMutableSet *endpointsInDevices = [NSMutableSet set];
for (MIKMIDIDevice *device in regularDevices) {
NSSet *sources = [NSSet setWithArray:[device.entities valueForKeyPath:@"@distinctUnionOfArrays.sources"]];
NSSet *destinations = [NSSet setWithArray:[device.entities valueForKeyPath:@"@distinctUnionOfArrays.destinations"]];
[endpointsInDevices unionSet:sources];
[endpointsInDevices unionSet:destinations];
}
NSMutableSet *devicelessSources = [NSMutableSet setWithArray:self.deviceManager.virtualSources];
NSMutableSet *devicelessDestinations = [NSMutableSet setWithArray:self.deviceManager.virtualDestinations];
[devicelessSources minusSet:endpointsInDevices];
[devicelessDestinations minusSet:endpointsInDevices];
// Now we need to try to associate each source with its corresponding destination on the same device
NSMapTable *destinationToSourceMap = [NSMapTable mapTableWithKeyOptions:NSMapTableStrongMemory valueOptions:NSMapTableStrongMemory];
NSMapTable *deviceNamesBySource = [NSMapTable mapTableWithKeyOptions:NSMapTableStrongMemory valueOptions:NSMapTableStrongMemory];
for (MIKMIDIEndpoint *source in devicelessSources) {
NSString *sourceName = [self deviceNameFromVirtualEndpoint:source];
for (MIKMIDIEndpoint *destination in devicelessDestinations) {
NSString *destinationName = [self deviceNameFromVirtualEndpoint:destination];
if ([sourceName isEqualToString:destinationName]) { // Source and destination match
[destinationToSourceMap setObject:destination forKey:source];
[deviceNamesBySource setObject:sourceName forKey:source];
break;
}
}
}
for (MIKMIDIEndpoint *source in destinationToSourceMap) {
MIKMIDIEndpoint *destination = [destinationToSourceMap objectForKey:source];
[devicelessSources removeObject:source];
[devicelessDestinations removeObject:destination];
MIKMIDIDevice *device = [MIKMIDIDevice deviceWithVirtualEndpoints:@[source, destination]];
device.name = [deviceNamesBySource objectForKey:source];
if (device) [result addObject:device];
}
for (MIKMIDIEndpoint *endpoint in devicelessSources) {
MIKMIDIDevice *device = [MIKMIDIDevice deviceWithVirtualEndpoints:@[endpoint]];
if (device) [result addObject:device];
}
}
self.availableDevices = [result copy];
}
- (MIKMIDIDevice *)firstAvailableDeviceWithName:(NSString *)deviceName
{
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"name == %@", deviceName];
return [[self.availableDevices filteredArrayUsingPredicate:predicate] firstObject];
}
#pragma mark Connection / Disconnection
- (BOOL)internalConnectToDevice:(MIKMIDIDevice *)device error:(NSError **)error
{
if ([self isConnectedToDevice:device]) return YES;
error = error ?: &(NSError *__autoreleasing){ nil };
__weak typeof(self) weakSelf = self;
id token = [self.deviceManager connectDevice:device error:error eventHandler:^(MIKMIDISourceEndpoint *endpoint, NSArray *commands) {
__strong typeof(self) strongSelf = weakSelf;
if (!strongSelf) { return; } // shouldn't actually happen
[strongSelf recordPendingNoteOnCommands:commands fromDevice:device];
[strongSelf removePendingNoteOnCommandsTerminatedByNoteOffCommands:commands fromDevice:device];
MIKMIDIEventHandlerBlock eventHandler = [strongSelf eventHandler];
if (eventHandler) { eventHandler(endpoint, commands); }
}];
if (!token) return NO;
[self.connectionTokensByDevice setObject:token forKey:device];
[self willChangeValueForKey:@"connectedDevices"
withSetMutation:NSKeyValueUnionSetMutation
usingObjects:[NSSet setWithObject:device]];
[self.internalConnectedDevices addObject:device];
[self didChangeValueForKey:@"connectedDevices"
withSetMutation:NSKeyValueUnionSetMutation
usingObjects:[NSSet setWithObject:device]];
__strong typeof(_delegate) delegate = self.delegate;
if ([delegate respondsToSelector:@selector(connectionManager:deviceWasConnected:)]) {
[delegate connectionManager:self deviceWasConnected:device];
}
return YES;
}
- (void)internalDisconnectFromDevice:(MIKMIDIDevice *)device
{
if (![self isConnectedToDevice:device]) return;
id token = [self.connectionTokensByDevice objectForKey:device];
if (!token) return;
[self.deviceManager disconnectConnectionForToken:token];
[self.connectionTokensByDevice removeObjectForKey:device];
[self willChangeValueForKey:@"connectedDevices"
withSetMutation:NSKeyValueMinusSetMutation
usingObjects:[NSSet setWithObject:device]];
[self.internalConnectedDevices removeObject:device];
[self didChangeValueForKey:@"connectedDevices"
withSetMutation:NSKeyValueMinusSetMutation
usingObjects:[NSSet setWithObject:device]];
__strong typeof(_delegate) delegate = self.delegate;
if ([delegate respondsToSelector:@selector(connectionManager:deviceWasDisconnected:withUnterminatedNoteOnCommands:)]) {
NSArray *pendingNoteOns = [self pendingNoteOnCommandsForDevice:device];
[delegate connectionManager:self deviceWasDisconnected:device withUnterminatedNoteOnCommands:pendingNoteOns];
}
if (self.automaticallySavesConfiguration) [self saveConfiguration];
}
- (void)scanAndConnectToInitialAvailableDevices
{
for (MIKMIDIDevice *device in self.availableDevices) {
[self connectToNewlyAddedDeviceIfAppropriate:device];
}
}
- (void)connectToNewlyAddedDeviceIfAppropriate:(MIKMIDIDevice *)device
{
if (!device) return;
MIKMIDIAutoConnectBehavior behavior = MIKMIDIAutoConnectBehaviorConnectIfPreviouslyConnectedOrNew;
__strong typeof(_delegate) delegate = self.delegate;
if ([delegate respondsToSelector:@selector(connectionManager:shouldConnectToNewlyAddedDevice:)]) {
behavior = [delegate connectionManager:self shouldConnectToNewlyAddedDevice:device];
}
BOOL shouldConnect = NO;
switch (behavior) {
case MIKMIDIAutoConnectBehaviorDoNotConnect:
shouldConnect = NO;
break;
case MIKMIDIAutoConnectBehaviorConnect:
shouldConnect = YES;
break;
case MIKMIDIAutoConnectBehaviorConnectOnlyIfPreviouslyConnected:
shouldConnect = [self deviceIsConnectedInSavedConfiguration:device];
break;
case MIKMIDIAutoConnectBehaviorConnectIfPreviouslyConnectedOrNew:
shouldConnect = ![self deviceIsUnconnectedInSavedConfiguration:device];
break;
}
if (shouldConnect) {
NSError *error = nil;
if (![self internalConnectToDevice:device error:&error]) {
NSLog(@"Unable to connect to MIDI device %@: %@", device, error);
return;
}
}
}
#pragma mark Configuration Persistence
- (NSString *)userDefaultsConfigurationKey
{
NSString *name = self.name;
if (![name length]) name = NSStringFromClass([self class]);
return [NSString stringWithFormat:@"%@SavedMIDIConnectionConfiguration", name];
}
- (NSDictionary *)savedConfiguration
{
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
return [userDefaults objectForKey:[self userDefaultsConfigurationKey]];
}
- (BOOL)deviceIsConnectedInSavedConfiguration:(MIKMIDIDevice *)device
{
NSString *deviceName = device.name;
if (![deviceName length]) return NO;
NSDictionary *configuration = [self savedConfiguration];
NSArray *connectedDeviceNames = configuration[MIKMIDIConnectionManagerConnectedDevicesKey];
return [connectedDeviceNames containsObject:deviceName];
}
- (BOOL)deviceIsUnconnectedInSavedConfiguration:(MIKMIDIDevice *)device
{
NSString *deviceName = device.name;
if (![deviceName length]) return NO;
NSDictionary *configuration = [self savedConfiguration];
NSArray *unconnectedDeviceNames = configuration[MIKMIDIConnectionManagerUnconnectedDevicesKey];
return [unconnectedDeviceNames containsObject:deviceName];
}
#pragma mark Virtual Endpoints
- (NSString *)deviceNameFromVirtualEndpoint:(MIKMIDIEndpoint *)endpoint
{
NSString *name = endpoint.name;
if (![name length]) name = [endpoint description];
NSCharacterSet *whitespace = [NSCharacterSet whitespaceCharacterSet];
NSMutableArray *nameComponents = [[name componentsSeparatedByCharactersInSet:whitespace] mutableCopy];
[nameComponents removeLastObject];
return [nameComponents componentsJoinedByString:@" "];
}
- (MIKMIDIDevice *)deviceContainingEndpoint:(MIKMIDIEndpoint *)endpoint
{
if (!endpoint) return nil;
NSMutableSet *devices = [NSMutableSet setWithArray:self.availableDevices];
[devices unionSet:self.connectedDevices];
for (MIKMIDIDevice *device in devices) {
NSMutableSet *deviceEndpoints = [NSMutableSet setWithArray:[device.entities valueForKeyPath:@"@distinctUnionOfArrays.sources"]];
[deviceEndpoints unionSet:[NSSet setWithArray:[device.entities valueForKeyPath:@"@distinctUnionOfArrays.destinations"]]];
if ([deviceEndpoints containsObject:endpoint]) return device;
}
return nil;
}
#pragma mark Pending Note Ons
- (NSMutableArray *)pendingNoteOnCommandsForDevice:(MIKMIDIDevice *)device
{
NSMutableArray *result = [self.pendingNoteOnsByDevice objectForKey:device];
if (!result) {
result = [NSMutableArray array];
[self.pendingNoteOnsByDevice setObject:result forKey:device];
}
return result;
}
- (void)recordPendingNoteOnCommands:(MIKArrayOf(MIKMIDICommand *) *)commands fromDevice:(MIKMIDIDevice *)device
{
commands = [commands filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id obj, NSDictionary *b) {
return [obj isKindOfClass:[MIKMIDINoteOnCommand class]];
}]];
if (![commands count]) return;
NSMutableArray *pendingNoteOns = [self pendingNoteOnCommandsForDevice:device];
[pendingNoteOns addObjectsFromArray:commands];
}
- (void)removePendingNoteOnCommandsTerminatedByNoteOffCommands:(MIKArrayOf(MIKMIDICommand *) *)commands fromDevice:(MIKMIDIDevice *)device
{
commands = [commands filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id obj, NSDictionary *b) {
if ([obj isKindOfClass:[MIKMIDINoteOnCommand class]] &&
[(MIKMIDINoteOnCommand *)obj velocity] == 0) {
return YES;
}
return [obj isKindOfClass:[MIKMIDINoteOffCommand class]];
}]];
if (![commands count]) return;
NSMutableArray *pendingNoteOns = [self pendingNoteOnCommandsForDevice:device];
if (![pendingNoteOns count]) return;
for (MIKMIDINoteOffCommand *noteOff in commands) {
for (MIKMIDINoteOnCommand *noteOn in [pendingNoteOns copy]) {
if (MIKMIDINoteOffCommandCorrespondsWithNoteOnCommand(noteOff, noteOn)) {
[pendingNoteOns removeObject:noteOn];
continue;
}
}
}
}
#pragma mark - Notifications
- (void)deviceWasPluggedIn:(NSNotification *)notification
{
MIKMIDIDevice *device = [notification userInfo][MIKMIDIDeviceKey];
[self connectToNewlyAddedDeviceIfAppropriate:device];
}
- (void)deviceWasUnplugged:(NSNotification *)notification
{
MIKMIDIDevice *unpluggedDevice = [notification userInfo][MIKMIDIDeviceKey];
[self internalDisconnectFromDevice:unpluggedDevice];
}
- (void)endpointWasPluggedIn:(NSNotification *)notification
{
MIKMIDIEndpoint *pluggedInEndpoint = [notification userInfo][MIKMIDIEndpointKey];
MIKMIDIDevice *pluggedInDevice = [self deviceContainingEndpoint:pluggedInEndpoint];
[self connectToNewlyAddedDeviceIfAppropriate:pluggedInDevice];
}
- (void)endpointWasUnplugged:(NSNotification *)notification
{
MIKMIDIEndpoint *unpluggedEndpoint = [notification userInfo][MIKMIDIEndpointKey];
MIKMIDIDevice *unpluggedDevice = [self deviceContainingEndpoint:unpluggedEndpoint];
if (unpluggedDevice) [self internalDisconnectFromDevice:unpluggedDevice];
}
- (void)saveConfigurationOnApplicationLifecycleEvent:(NSNotification *)notification
{
if (self.automaticallySavesConfiguration) {
[self saveConfiguration];
}
}
#pragma mark - KVO
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context
{
if (context != MIKMIDIConnectionManagerKVOContext) {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
return;
}
if (object != self.deviceManager) return;
if ([keyPath isEqualToString:@"availableDevices"]) {
[self updateAvailableDevices];
}
if (self.includesVirtualDevices &&
([keyPath isEqualToString:@"virtualSources"] || [keyPath isEqualToString:@"virtualDestinations"])) {
[self updateAvailableDevices];
}
}
#pragma mark - Properties
- (MIKMIDIDeviceManager *)deviceManager { return [MIKMIDIDeviceManager sharedDeviceManager]; }
- (MIKMIDIEventHandlerBlock)eventHandler
{
return _eventHandler ?: ^(MIKMIDISourceEndpoint *s, NSArray *c){};
}
- (void)setIncludesVirtualDevices:(BOOL)includesVirtualDevices
{
if (includesVirtualDevices != _includesVirtualDevices) {
_includesVirtualDevices = includesVirtualDevices;
[self updateAvailableDevices];
}
}
- (void)setAvailableDevices:(NSArray *)availableDevices
{
if (availableDevices != _availableDevices) {
// Disconnect from newly unavailable devices.
// This will include "partial" virtual devices that are now complete
// by virtue of having been notified of other sources for them.
for (MIKMIDIDevice *device in self.connectedDevices) {
if (![availableDevices containsObject:device]) {
[self internalDisconnectFromDevice:device];
}
}
_availableDevices = availableDevices;
}
}
+ (BOOL)automaticallyNotifiesObserversOfConnectedDevices { return NO; }
- (MIKSetOf(MIKMIDIDevice *) *)connectedDevices
{
return [self.internalConnectedDevices copy];
}
@end
BOOL MIKMIDINoteOffCommandCorrespondsWithNoteOnCommand(MIKMIDINoteOffCommand *noteOff, MIKMIDINoteOnCommand *noteOn)
{
if (noteOff.channel != noteOn.channel) return NO;
if (noteOff.note != noteOn.note) return NO;
if ([noteOff.timestamp compare:noteOn.timestamp] != NSOrderedAscending) return NO;
return YES;
}