Введение

Начиная с macOS 12.3 Apple наконец-то предоставляет новую возможность для создания  виртуальной веб-камеры - с помощью Camera Extension. Это такой новый тип системных расширений (System Extensions) - механизма, появившегося в macOS 10.15 (Catalina). Системные расширения призваны заменить расширения ядра (Kernel Extensions, KEXTs), выполняя те же самые функции по расширению возможностей macOS на низком уровне, но при этом работая в пространстве пользователя (user space). Camera Extensions также были созданы для замены старого механизма создания виртуальных камер - через плагины CoreMedia I/O DAL Plug-ins. Одним из главных недостатков этих плагинов является то, что они, как и любой плагин, должны загружаться в каждый процесс (клиентского приложения), который хочет использовать камеру, представляемую этим плагином. С этой загрузкой в последнее время начали возникать проблемы из-за различных мер по обеспечению безопасности, которые Apple стала планомерно внедрять в свою ОС. Но даже когда ограничения позволяют загрузить сторонний плагин в процесс клиентского приложения, если в этом приложении включён sandboxing, то плагин становится полностью изолированным и не может получать исходные данные для камеры откуда бы ни было. Именно эти недостатки и призван преодолеть Camera Extension, который запускается системой в своём собственном процессе, а его взаимодействие и отправку видеоданных клиентским приложениям ОС также берёт на себя.

В 32-минутном ролике сотрудник Apple лихо демонстрирует, как создать такое расширение, а также приложение, которое передает в него некие данные. Само расширение создается из шаблона, нужно подправить пару деталей и вуаля - все готово. Конечно же, на самом деле все гораздо сложней. Кроме того, что опущены различные детали безопасности, без которых ничего не заработает, сам механизм передачи данных рассмотрен настолько бегло, что можно говорить о том, что он практически не документирован.

Итак, у нас есть рабочий прототип системного расширения с камерой, осталось приделать к нему передачу видео потока и управляющих сигналов из нашего приложения. Естественным решением выглядит использование XPC - механизма межпроцессной коммуникации. Все указывает на то, что по аналогии с другой технологией виртуальной камеры (DAL) нужно соединяться через XPC с сервисом Mach Service, в роли которого выступает наш Camera Extension. Для создания такого XPC-соединения в Objective-C классе NSXPCConnection есть метод initWithMachServiceName:. Но выяснилось, что установить такое XPC-соединение невозможно, по крайней мере, обычным способом, без использования хаков. Скорее всего, это связано с тем, что процесс Camera Extension запускается под другим юзером (_cmiodalassistants) и поэтому не виден из процесса нашего приложения. Во всяком случае, при попытке установить соединение (вызов метода remoteObjectProxyWithErrorHandler: у объекта NSXPCConnection) получаем ошибку с кодом 4099 из NSCocoaErrorDomain ("Couldn’t communicate with a helper application."), с описанием:                                                                                      

{
    NSDebugDescription = "The connection to service named
XXXXXXXXX.com.company.appName.cameraExtension was invalidated: failed at
lookup with error 3 - No such process."
; }

Как выясняется, XPC-соединения - не тот способ, которыми можно обмениваться данными между приложением и Camera Extension. Именно такие рекомендации дают на девелоперском форуме Apple: https://developer.apple.com/forums/thread/706184?answerId=723807022#723807022. Вместо них для передачи кадров в Camera Extension предлагают использовать sink stream, т.е. объявить в виртуальной камере возможность ввода данных в неё. Давайте реализуем этот вариант.

Входной поток (Sink Stream)

Отличие создания объекта sink stream от source stream это указание направления этого потока CMIOExtensionStreamDirectionSink. В расширении:

stream = [[CMIOExtensionStream alloc] 
    initWithLocalizedName:localizedName 
    streamID:streamID 
    direction:CMIOExtensionStreamDirectionSink
    clockType:CMIOExtensionStreamClockTypeHostTime
    source:self];

Затем созданный экземпляр объекта добавляется как второй stream в объект CMIOExtensionDevice по аналогии с Source Stream

[device addStream:stream error:&error]

Теперь давайте разберемся, как подключиться к расширению из приложения.

Для того, чтобы подключиться к запущенному расширению для передачи стрима и чтения/записи его параметров необходимо:

    1. Получить список девайсов и найти нужный нам (например по имени device.localizedName):

auto discoverySession = [AVCaptureDeviceDiscoverySession
    discoverySessionWithDeviceTypes: @[AVCaptureDeviceTypeExternalUnknown]
    mediaType: AVMediaTypeVideo
    position: AVCaptureDevicePositionUnspecified];
devices = discoverySession.devices;

    2. Получить значение CMIOObjectID по свойству uniqueId класса AVCaptureDevice, для этого получаем список девайсов, но уже используя другое CMIO API:

UInt32 dataSize = 0;
UInt32 dataUsed = 0;
CMIOObjectPropertyAddress opa = {
    CMIOObjectPropertySelector(kCMIOHardwarePropertyDevices),
    CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
    CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
};

CMIOObjectGetPropertyDataSize(CMIOObjectPropertySelector(kCMIOObjectSystemObject),
    &opa, 0, nil, &dataSize);

auto devicesCount = int(dataSize) / sizeof(CMIOObjectID);
std::vector<CMIOObjectID> devices(devicesCount);

CMIOObjectGetPropertyData(CMIOObjectPropertySelector(kCMIOObjectSystemObject),
    &opa, 0, nil, dataSize, &dataUsed, devices.data());

    3. Найти среди всех девайсов устройство с UID равным uniqueId, из найденного AVCaptureDevice через свойство kCMIODevicePropertyDeviceUID:

opa.mSelector = CMIOObjectPropertySelector(kCMIODevicePropertyDeviceUID);
CMIOObjectGetPropertyDataSize(devices[deviceObjectIndex],
    &opa, 0, nil, &dataSize);

CFStringRef cfUID = NULL;
CMIOObjectGetPropertyData(devices[deviceObjectIndex],
    &opa, 0, nil, dataSize, &dataUsed, &cfUID);

   4. В результате мы имеем значение CMIOObjectID устройства, которое поможет получить доступ к списку его потоков. Для получения списка потоков также необходимо воспользоваться CMIO API по этому device id:

UInt32 dataSize = 0;
UInt32 dataUsed = 0;
CMIOObjectPropertyAddress opa = {
    CMIOObjectPropertySelector(kCMIODevicePropertyStreams),
    CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
    CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
};

CMIOObjectGetPropertyDataSize(deviceId, &opa, 0, nil, &dataSize);

int streamsCount = int(dataSize) / sizeof(CMIOStreamID);
std::vector<CMIOStreamID> streamIds(streamsCount);

CMIOObjectGetPropertyData(deviceId, &opa, 0, nil, dataSize, 
    &dataUsed, streamIds.data());

  В случае успеха этих операций у нас будет список потоков, зарегистрированных в расширении. Мы выбираем поток, работающий в направлении CMIOExtensionStreamDirectionSink - он был добавлен вторым. Значение CMIOStreamID как раз таки и характеризует этот поток. Далее необходимо просто начать поток следующим методом:

OSStatus CMIODeviceStartStream(CMIODeviceID  deviceID, CMIOStreamID streamID)

Однако простого создания потока будет мало, так как мы хотим отправлять в него видео, мы должны создать объект CMSimpleQueue методом CMSimpleQueueCreate, а также "привязать" эту очередь к самому потоку методом CMIOStreamCopyBufferQueue

CMSimpleQueueCreate(kCFAllocatorDefault, 5, &sinkQueue);

CMIODeviceStreamQueueAlteredProc handler 
    = [](CMIOStreamID streamID, void* token, void* refCon) {
            auto self = static_cast<TSClassName*>(refCon);
            self->_readyToEnqueue = true;
    };

CMIOStreamCopyBufferQueue(sinkStream, handler, this, &sinkQueue);

Где sinkQueue определена как поле класса типа CMSimpleQueueRef. В дальнейшем это поле используется для отправки изображения в этот поток методом CMSimpleQueueEnqueue.

Пользовательские свойства (Custom Properties)

Другой способ передачи данных - custom properties. Мы использовали эти свойства для передачи управляющей информации, то есть небольших (относительно видео) объемов данных. Использование свойств для передачи видео-потока представляется возможным, но не исследовалось на предмет производительности.

Добавить свойство можно в любой из объектов, CMIOExtensionDeviceSource или CMIOExtensionStreamSource, но давайте сделаем это в устройство. Просто добавим несколько новых строк в нужном формате в уже существующий метод availableProperties.

@implementation DeviceSource

- (NSSet<CMIOExtensionProperty> *)availableProperties
{
    return [NSSet setWithObjects:
        	CMIOExtensionPropertyDeviceTransportType,
        	CMIOExtensionPropertyDeviceModel,
        	@"4cc_clie_glob_0000", // кастомное свойство, положите в константу
        	@"4cc_reso_glob_0000", // кастомное свойство
        	nil];
}

Мы добавили 2 новых свойства, clie - clients (список клиентов), reso - resolution. Имя свойства задается в формате: 4cc_selector_scope_element. Где “4cc” константа, затем 4-хзначный селектор (4сс == 4 character code), затем scope и element, все разделены подчеркиванием. В наших примерах мы всегда будем использовать scope = global, а element 0000, что значит main, но можно использовать любое число. Таким образом, мы будем задавать разные свойства, меняя только 4х-символьный селектор.

Добавленные таким образом свойства будут доступны из приложения посредством такой структуры:

CMIOObjectPropertyAddress propertyAddress = {
	CMIOObjectPropertySelector(FOUR_CHAR_CODE('clie')), // clie
	CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), // glob
	CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) // 0000
};

Где макрос FOUR_CHAR_CODE создаст селектор с нужным 4х-символьным кодом.

Давайте попробуем передать произвольную структуру данных из расширения в приложение. Для обмена удобнее всего объявить в общем для расширения и приложения заголовочном файле структуру, например:

typedef struct TSResolution {
    uint32_t  width;
    uint32_t  height;
} TSResolution;

В существующий метод нашего устройства, добавим отдачу нового свойства:

- (nullable CMIOExtensionDeviceProperties *)
    devicePropertiesForProperties:(NSSet<CMIOExtensionProperty> *)properties
    error:(NSError * _Nullable *)outError
{
    CMIOExtensionDeviceProperties *deviceProperties 
        = [CMIOExtensionDeviceProperties devicePropertiesWithDictionary:@{}];
// …
if ([properties containsObject:@"4cc_reso_glob_0000"]) {
    	TSResolution res = {.width = 1920, .height = 1080};
    	NSData * resData = [NSData dataWithBytes:&res length:sizeof(res)];
    	CMIOExtensionPropertyState* propertyState
           = [CMIOExtensionPropertyState propertyStateWithValue:resData];
    	[deviceProperties setPropertyState:propertyState 
             forProperty:@"4cc_reso_glob_0000"];
}
    return deviceProperties;
}

Как видим, мы просто создаем объект NSData, содержащий копию данных нашей структуры и выставляем этот объект как состояние соответствующего свойства. В качестве данных можно использовать другие объекты, например строки NSString.

Теперь можно прочитать эти данные обратно из приложения. Для этого нужен буфер, например std::vector или NSMutableData. Подключаемся к нашему объекту (см. выше п.2), здесь и далее CMIOObjectID objectID. Пример (проверки пропущены):

CMIOObjectPropertyAddress propertyAddress = {
    CMIOObjectPropertySelector(FOUR_CHAR_CODE('reso')),
    CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
    CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
};

UInt32 dataSize = 0;
UInt32 dataUsed = 0;
OSStatus status = CMIOObjectGetPropertyDataSize(objectID,
     &propertyAddress, 0, nil, &dataSize);

if (status != 0) {
    return status;
}

std::vector<char> buffer(dataSize);
status = CMIOObjectGetPropertyData(objectID,
    &propertyAddress, 0, nil, dataSize, &dataUsed,
    (void *)buffer.data());

TSResolution * res = (TSResolution *)buffer.data();
// res->width; res->height

Все хорошо, однако мы заметили, что повторный вызов получения данных свойства через CMIOObjectGetPropertyData не приводит к повторному чтению данных из метода devicePropertiesForProperties. Нам просто вернутся те же самые данные, что и при первом вызове, а сам вызов не дойдет до нашего кода. Дело в том, что если данные кастомного свойства поменялись, то мы должны явно об этом уведомить.

Для того чтобы вычитывать каждый раз новое значение, мы будем перед чтением вызывать запись свойства, чтобы сбросить его в расширении.      

CMIOObjectSetPropertyData(objectID,
    &propertyAddress,
    0,
    nil,
    dataSize,
    (void *)buffer.data());
// CMIOObjectGetPropertyData // потом читаем

При этом в расширении саму операцию записи можно игнорировать, просто уведомлять об изменении:

- (BOOL)setDeviceProperties:(CMIOExtensionDeviceProperties *)deviceProperties error:(NSError * _Nullable *)outError
{
    NSDictionary<CMIOExtensionProperty, CMIOExtensionPropertyState *>* dictionary
        = [deviceProperties propertiesDictionary];
    [self.device notifyPropertiesChanged:dictionary]; // уведомляем об изменении

    return YES;
}

Теперь перед чтением мы будем сбрасывать кеш значения и каждый раз получать новое состояние.

Однако, если нам все же нужно прочитать переданное значение из приложения? То в расширении в метод setDeviceProperties можно добавить:

CMIOExtensionPropertyState* resProperty
    = [dictionary objectForKey:@"4cc_reso_glob_0000"];
if (resProperty && resProperty.value) {
    NSData * resData = (NSData *)resProperty.value;
    TSResolution * res = (TSResolution *)[resData bytes];
}

Передача строки осуществляется несколько проще. В расширении:

CMIOExtensionPropertyState* propertyState 
    = [CMIOExtensionPropertyState propertyStateWithValue:@"String"];
[deviceProperties 
    setPropertyState:propertyState 
    forProperty:@"4cc_prop_glob_0000"];

В приложении:

UInt32 dataSize = 0;
UInt32 dataUsed = 0;
CFStringRef cfStrData = NULL;

CMIOObjectGetPropertyDataSize(objectID,
    &propertyAddress, 0, nil, &dataSize);

CMIOObjectGetPropertyData(objectID,
    &propertyAddress, 0, nil, dataSize, &dataUsed, &cfStrData);

NSString * data = (__bridge NSString *)cfStrData;