Init
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
//
|
||||
// CamExtensionProvider.swift
|
||||
// CamExtension
|
||||
//
|
||||
// Created by Vlad on 9/12/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreMediaIO
|
||||
import CoreVideo
|
||||
import CoreGraphics
|
||||
import CoreImage
|
||||
import IOKit.audio
|
||||
import IOSurface
|
||||
import Security
|
||||
import ObjectiveC
|
||||
import os.log
|
||||
|
||||
let kFrameRate: Int = 30
|
||||
let kPortalCamDeviceUUID = UUID(uuidString: "7B1E8C4B-8D2E-4A0D-9B95-8F5C1C7D9B11")!
|
||||
let kPortalCamSourceStreamUUID = UUID(uuidString: "7B1E8C4B-8D2E-4A0D-9B95-8F5C1C7D9B12")!
|
||||
let kPortalCamSinkStreamUUID = UUID(uuidString: "7B1E8C4B-8D2E-4A0D-9B95-8F5C1C7D9B13")!
|
||||
|
||||
let kStreamStartedNotification = "com.kovtash.portalcam.streamStarted"
|
||||
let kStreamStoppedNotification = "com.kovtash.portalcam.streamStopped"
|
||||
|
||||
@_silgen_name("csops")
|
||||
func csops(_ pid: pid_t, _ ops: UInt32, _ useraddr: UnsafeMutableRawPointer?, _ usersize: Int) -> Int32
|
||||
|
||||
// MARK: - Device Source
|
||||
|
||||
class CamExtensionDeviceSource: NSObject, CMIOExtensionDeviceSource {
|
||||
|
||||
private(set) var device: CMIOExtensionDevice!
|
||||
|
||||
private var _streamSource: CamExtensionStreamSource!
|
||||
private var _sinkStreamSource: CamExtensionSinkStreamSource!
|
||||
|
||||
private var _streamingCounter: UInt32 = 0
|
||||
private var _videoDescription: CMFormatDescription!
|
||||
private var _bufferPool: CVPixelBufferPool!
|
||||
|
||||
private var _placeholderPixelBuffer: CVPixelBuffer?
|
||||
private var _lastSinkFrameDate: Date?
|
||||
private let _lastSinkFrameLock = NSLock()
|
||||
|
||||
private var _placeholderTimer: DispatchSourceTimer?
|
||||
private let _timerQueue = DispatchQueue(label: "com.kovtash.portalcam.cmio", qos: .userInteractive)
|
||||
private var _sentFrames = 0
|
||||
|
||||
init(localizedName: String) {
|
||||
super.init()
|
||||
|
||||
self.device = CMIOExtensionDevice(
|
||||
localizedName: localizedName,
|
||||
deviceID: kPortalCamDeviceUUID,
|
||||
legacyDeviceID: nil,
|
||||
source: self
|
||||
)
|
||||
|
||||
let dims = CMVideoDimensions(width: 1280, height: 720)
|
||||
CMVideoFormatDescriptionCreate(
|
||||
allocator: kCFAllocatorDefault,
|
||||
codecType: kCVPixelFormatType_32BGRA,
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
extensions: nil,
|
||||
formatDescriptionOut: &_videoDescription
|
||||
)
|
||||
|
||||
let pixelBufferAttributes: NSDictionary = [
|
||||
kCVPixelBufferWidthKey: dims.width,
|
||||
kCVPixelBufferHeightKey: dims.height,
|
||||
kCVPixelBufferPixelFormatTypeKey: _videoDescription.mediaSubType,
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [:] as NSDictionary
|
||||
]
|
||||
CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, pixelBufferAttributes, &_bufferPool)
|
||||
|
||||
let videoStreamFormat = CMIOExtensionStreamFormat(
|
||||
formatDescription: _videoDescription,
|
||||
maxFrameDuration: CMTime(value: 1, timescale: Int32(kFrameRate)),
|
||||
minFrameDuration: CMTime(value: 1, timescale: Int32(kFrameRate)),
|
||||
validFrameDurations: nil
|
||||
)
|
||||
|
||||
_streamSource = CamExtensionStreamSource(
|
||||
localizedName: "PortalCam",
|
||||
streamID: kPortalCamSourceStreamUUID,
|
||||
streamFormat: videoStreamFormat,
|
||||
device: device
|
||||
)
|
||||
|
||||
_sinkStreamSource = CamExtensionSinkStreamSource(
|
||||
localizedName: "PortalCam Sink",
|
||||
streamID: kPortalCamSinkStreamUUID,
|
||||
streamFormat: videoStreamFormat,
|
||||
device: device,
|
||||
deviceSource: self
|
||||
)
|
||||
|
||||
do {
|
||||
try device.addStream(_streamSource.stream)
|
||||
try device.addStream(_sinkStreamSource.stream)
|
||||
} catch {
|
||||
fatalError("Failed to add streams: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
createPlaceholderBuffer(width: Int(dims.width), height: Int(dims.height))
|
||||
}
|
||||
|
||||
private func createPlaceholderBuffer(width: Int, height: Int) {
|
||||
let attrs: [CFString: Any] = [
|
||||
kCVPixelBufferCGImageCompatibilityKey: true,
|
||||
kCVPixelBufferCGBitmapContextCompatibilityKey: true,
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [:] as NSDictionary
|
||||
]
|
||||
let status = CVPixelBufferCreate(
|
||||
kCFAllocatorDefault,
|
||||
width,
|
||||
height,
|
||||
kCVPixelFormatType_32BGRA,
|
||||
attrs as CFDictionary,
|
||||
&_placeholderPixelBuffer
|
||||
)
|
||||
guard status == kCVReturnSuccess, let buffer = _placeholderPixelBuffer else { return }
|
||||
|
||||
CVPixelBufferLockBaseAddress(buffer, [])
|
||||
defer { CVPixelBufferUnlockBaseAddress(buffer, []) }
|
||||
guard let base = CVPixelBufferGetBaseAddress(buffer) else { return }
|
||||
let bytesPerRow = CVPixelBufferGetBytesPerRow(buffer)
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue
|
||||
guard let ctx = CGContext(
|
||||
data: base,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: bytesPerRow,
|
||||
space: colorSpace,
|
||||
bitmapInfo: bitmapInfo
|
||||
) else { return }
|
||||
|
||||
// Fill dark slate background
|
||||
ctx.setFillColor(red: 0.10, green: 0.12, blue: 0.16, alpha: 1.0)
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
|
||||
// Rounded border
|
||||
let insetRect = CGRect(x: 40, y: 40, width: width - 80, height: height - 80)
|
||||
let path = CGPath(roundedRect: insetRect, cornerWidth: 20, cornerHeight: 20, transform: nil)
|
||||
ctx.addPath(path)
|
||||
ctx.setStrokeColor(red: 0.28, green: 0.45, blue: 0.75, alpha: 0.8)
|
||||
ctx.setLineWidth(4)
|
||||
ctx.strokePath()
|
||||
}
|
||||
|
||||
var availableProperties: Set<CMIOExtensionProperty> {
|
||||
return [.deviceTransportType, .deviceModel]
|
||||
}
|
||||
|
||||
func deviceProperties(forProperties properties: Set<CMIOExtensionProperty>) throws -> CMIOExtensionDeviceProperties {
|
||||
let deviceProperties = CMIOExtensionDeviceProperties(dictionary: [:])
|
||||
if properties.contains(.deviceTransportType) {
|
||||
deviceProperties.transportType = kIOAudioDeviceTransportTypeVirtual
|
||||
}
|
||||
if properties.contains(.deviceModel) {
|
||||
deviceProperties.model = "PortalCam Camera"
|
||||
}
|
||||
return deviceProperties
|
||||
}
|
||||
|
||||
func setDeviceProperties(_ deviceProperties: CMIOExtensionDeviceProperties) throws {
|
||||
// Settable properties
|
||||
}
|
||||
|
||||
func startStreaming() {
|
||||
_streamingCounter += 1
|
||||
os_log(.default, "PortalCam extension: startStreaming (counter=%{public}u)", _streamingCounter)
|
||||
|
||||
if _streamingCounter == 1 {
|
||||
// Notify main app that a client has opened the camera
|
||||
if let center = CFNotificationCenterGetDarwinNotifyCenter() {
|
||||
CFNotificationCenterPostNotification(
|
||||
center,
|
||||
CFNotificationName(kStreamStartedNotification as CFString),
|
||||
nil,
|
||||
nil,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
// Schedule fallback/standby timer
|
||||
_placeholderTimer = DispatchSource.makeTimerSource(queue: _timerQueue)
|
||||
_placeholderTimer?.schedule(deadline: .now(), repeating: 1.0 / Double(kFrameRate), leeway: .milliseconds(2))
|
||||
_placeholderTimer?.setEventHandler { [weak self] in
|
||||
self?.sendPlaceholderIfNeeded()
|
||||
}
|
||||
_placeholderTimer?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func stopStreaming() {
|
||||
if _streamingCounter > 1 {
|
||||
_streamingCounter -= 1
|
||||
} else {
|
||||
_streamingCounter = 0
|
||||
os_log(.default, "PortalCam extension: stopStreaming - posting stop notification")
|
||||
|
||||
_placeholderTimer?.cancel()
|
||||
_placeholderTimer = nil
|
||||
|
||||
_lastSinkFrameLock.lock()
|
||||
_lastSinkFrameDate = nil
|
||||
_lastSinkFrameLock.unlock()
|
||||
|
||||
if let center = CFNotificationCenterGetDarwinNotifyCenter() {
|
||||
CFNotificationCenterPostNotification(
|
||||
center,
|
||||
CFNotificationName(kStreamStoppedNotification as CFString),
|
||||
nil,
|
||||
nil,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPlaceholderIfNeeded() {
|
||||
guard _streamingCounter > 0 else { return }
|
||||
|
||||
_lastSinkFrameLock.lock()
|
||||
let lastDate = _lastSinkFrameDate
|
||||
_lastSinkFrameLock.unlock()
|
||||
|
||||
// If live sink frames are actively arriving within the last 500ms, don't send placeholder
|
||||
if let lastDate = lastDate, Date().timeIntervalSince(lastDate) < 0.5 {
|
||||
return
|
||||
}
|
||||
|
||||
guard let placeholder = _placeholderPixelBuffer else { return }
|
||||
|
||||
let hostTime = CMClockGetTime(CMClockGetHostTimeClock())
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: CMTime(value: 1, timescale: Int32(kFrameRate)),
|
||||
presentationTimeStamp: hostTime,
|
||||
decodeTimeStamp: .invalid
|
||||
)
|
||||
|
||||
var sample: CMSampleBuffer?
|
||||
let status = CMSampleBufferCreateForImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: placeholder,
|
||||
dataReady: true,
|
||||
makeDataReadyCallback: nil,
|
||||
refcon: nil,
|
||||
formatDescription: _videoDescription,
|
||||
sampleTiming: &timing,
|
||||
sampleBufferOut: &sample
|
||||
)
|
||||
|
||||
if status == noErr, let sample = sample {
|
||||
let nano = UInt64(hostTime.seconds * Double(NSEC_PER_SEC))
|
||||
_streamSource.stream.send(sample, discontinuity: [], hostTimeInNanoseconds: nano)
|
||||
}
|
||||
}
|
||||
|
||||
func deliverSinkSampleBuffer(_ sampleBuffer: CMSampleBuffer) {
|
||||
_lastSinkFrameLock.lock()
|
||||
_lastSinkFrameDate = Date()
|
||||
_lastSinkFrameLock.unlock()
|
||||
|
||||
guard _streamingCounter > 0 else { return }
|
||||
|
||||
let hostTime = CMClockGetTime(CMClockGetHostTimeClock())
|
||||
let nano = UInt64(hostTime.seconds * Double(NSEC_PER_SEC))
|
||||
|
||||
_sentFrames += 1
|
||||
if _sentFrames <= 3 {
|
||||
os_log(.default, "PortalCam extension: forwarded sink frame %{public}d to source stream", self._sentFrames)
|
||||
}
|
||||
|
||||
_streamSource.stream.send(sampleBuffer, discontinuity: [], hostTimeInNanoseconds: nano)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Stream (FaceTime, Zoom, Photo Booth)
|
||||
|
||||
class CamExtensionStreamSource: NSObject, CMIOExtensionStreamSource {
|
||||
|
||||
private(set) var stream: CMIOExtensionStream!
|
||||
let device: CMIOExtensionDevice
|
||||
private let _streamFormat: CMIOExtensionStreamFormat
|
||||
|
||||
init(localizedName: String, streamID: UUID, streamFormat: CMIOExtensionStreamFormat, device: CMIOExtensionDevice) {
|
||||
self.device = device
|
||||
self._streamFormat = streamFormat
|
||||
super.init()
|
||||
self.stream = CMIOExtensionStream(
|
||||
localizedName: localizedName,
|
||||
streamID: streamID,
|
||||
direction: .source,
|
||||
clockType: .hostTime,
|
||||
source: self
|
||||
)
|
||||
}
|
||||
|
||||
var formats: [CMIOExtensionStreamFormat] {
|
||||
return [_streamFormat]
|
||||
}
|
||||
|
||||
var activeFormatIndex: Int = 0 {
|
||||
didSet {
|
||||
if activeFormatIndex >= 1 {
|
||||
os_log(.error, "Invalid index: %{public}d", activeFormatIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var availableProperties: Set<CMIOExtensionProperty> {
|
||||
return [.streamActiveFormatIndex, .streamFrameDuration]
|
||||
}
|
||||
|
||||
func streamProperties(forProperties properties: Set<CMIOExtensionProperty>) throws -> CMIOExtensionStreamProperties {
|
||||
let streamProperties = CMIOExtensionStreamProperties(dictionary: [:])
|
||||
if properties.contains(.streamActiveFormatIndex) {
|
||||
streamProperties.activeFormatIndex = 0
|
||||
}
|
||||
if properties.contains(.streamFrameDuration) {
|
||||
streamProperties.frameDuration = CMTime(value: 1, timescale: Int32(kFrameRate))
|
||||
}
|
||||
return streamProperties
|
||||
}
|
||||
|
||||
func setStreamProperties(_ streamProperties: CMIOExtensionStreamProperties) throws {
|
||||
if let activeFormatIndex = streamProperties.activeFormatIndex {
|
||||
self.activeFormatIndex = activeFormatIndex
|
||||
}
|
||||
}
|
||||
|
||||
func authorizedToStartStream(for client: CMIOExtensionClient) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func startStream() throws {
|
||||
guard let deviceSource = device.source as? CamExtensionDeviceSource else {
|
||||
fatalError("Unexpected source type \(String(describing: device.source))")
|
||||
}
|
||||
deviceSource.startStreaming()
|
||||
}
|
||||
|
||||
func stopStream() throws {
|
||||
guard let deviceSource = device.source as? CamExtensionDeviceSource else {
|
||||
fatalError("Unexpected source type \(String(describing: device.source))")
|
||||
}
|
||||
deviceSource.stopStreaming()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sink Stream (fed by PortalCam.app)
|
||||
|
||||
class CamExtensionSinkStreamSource: NSObject, CMIOExtensionStreamSource {
|
||||
|
||||
private(set) var stream: CMIOExtensionStream!
|
||||
let device: CMIOExtensionDevice
|
||||
weak var deviceSource: CamExtensionDeviceSource?
|
||||
private let _streamFormat: CMIOExtensionStreamFormat
|
||||
|
||||
private let _consumeQueue = DispatchQueue(label: "com.kovtash.portalcam.sink.consume", qos: .userInteractive)
|
||||
private var _isStreaming: Bool = false
|
||||
private var _activeClient: CMIOExtensionClient?
|
||||
|
||||
init(
|
||||
localizedName: String,
|
||||
streamID: UUID,
|
||||
streamFormat: CMIOExtensionStreamFormat,
|
||||
device: CMIOExtensionDevice,
|
||||
deviceSource: CamExtensionDeviceSource
|
||||
) {
|
||||
self.device = device
|
||||
self.deviceSource = deviceSource
|
||||
self._streamFormat = streamFormat
|
||||
super.init()
|
||||
self.stream = CMIOExtensionStream(
|
||||
localizedName: localizedName,
|
||||
streamID: streamID,
|
||||
direction: .sink,
|
||||
clockType: .hostTime,
|
||||
source: self
|
||||
)
|
||||
}
|
||||
|
||||
var formats: [CMIOExtensionStreamFormat] {
|
||||
return [_streamFormat]
|
||||
}
|
||||
|
||||
var activeFormatIndex: Int = 0 {
|
||||
didSet {
|
||||
if activeFormatIndex >= 1 {
|
||||
os_log(.error, "Invalid sink format index: %{public}d", activeFormatIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var availableProperties: Set<CMIOExtensionProperty> {
|
||||
return [
|
||||
.streamActiveFormatIndex,
|
||||
.streamFrameDuration,
|
||||
.streamSinkBufferQueueSize,
|
||||
.streamSinkBuffersRequiredForStartup
|
||||
]
|
||||
}
|
||||
|
||||
func streamProperties(forProperties properties: Set<CMIOExtensionProperty>) throws -> CMIOExtensionStreamProperties {
|
||||
let streamProperties = CMIOExtensionStreamProperties(dictionary: [:])
|
||||
if properties.contains(.streamActiveFormatIndex) {
|
||||
streamProperties.activeFormatIndex = 0
|
||||
}
|
||||
if properties.contains(.streamFrameDuration) {
|
||||
streamProperties.frameDuration = CMTime(value: 1, timescale: Int32(kFrameRate))
|
||||
}
|
||||
if properties.contains(.streamSinkBufferQueueSize) {
|
||||
streamProperties.sinkBufferQueueSize = 8
|
||||
}
|
||||
if properties.contains(.streamSinkBuffersRequiredForStartup) {
|
||||
streamProperties.sinkBuffersRequiredForStartup = 1
|
||||
}
|
||||
return streamProperties
|
||||
}
|
||||
|
||||
func setStreamProperties(_ streamProperties: CMIOExtensionStreamProperties) throws {
|
||||
if let activeFormatIndex = streamProperties.activeFormatIndex {
|
||||
self.activeFormatIndex = activeFormatIndex
|
||||
}
|
||||
}
|
||||
|
||||
func authorizedToStartStream(for client: CMIOExtensionClient) -> Bool {
|
||||
guard isValidPortalCamClient(client: client) else {
|
||||
os_log(.error, "PortalCam extension: rejected unauthorized sink client PID %d, signingID %{public}@",
|
||||
client.pid, client.signingID ?? "unknown")
|
||||
return false
|
||||
}
|
||||
_activeClient = client
|
||||
os_log(.default, "PortalCam extension: authorized verified sink client PID %d, signingID %{public}@",
|
||||
client.pid, client.signingID ?? "unknown")
|
||||
return true
|
||||
}
|
||||
|
||||
private func isValidPortalCamClient(client: CMIOExtensionClient) -> Bool {
|
||||
let pid = client.pid
|
||||
|
||||
// 1. Check code signing status via kernel csops
|
||||
// CS_OPS_STATUS = 0, CS_VALID = 0x00000001
|
||||
var status: UInt32 = 0
|
||||
let statusRet = csops(pid, 0, &status, MemoryLayout<UInt32>.size)
|
||||
guard statusRet == 0 else {
|
||||
os_log(.error, "PortalCam extension: csops CS_OPS_STATUS failed for PID %d: errno %d", pid, errno)
|
||||
return false
|
||||
}
|
||||
|
||||
guard (status & 0x00000001) != 0 else {
|
||||
os_log(.error, "PortalCam extension: process %d code signature is not valid (status 0x%{public}x)", pid, status)
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Check code signing identity (Bundle Identifier)
|
||||
struct CSHeader {
|
||||
var magic: UInt32 = 0
|
||||
var length: UInt32 = 0
|
||||
}
|
||||
|
||||
let CS_OPS_IDENTITY: UInt32 = 11
|
||||
var idHeader = CSHeader()
|
||||
let idHeaderRet = csops(pid, CS_OPS_IDENTITY, &idHeader, MemoryLayout<CSHeader>.size)
|
||||
guard idHeaderRet == 0 || errno == ERANGE else {
|
||||
os_log(.error, "PortalCam extension: csops CS_OPS_IDENTITY header failed for PID %d: errno %d", pid, errno)
|
||||
return false
|
||||
}
|
||||
let idLen = Int(UInt32(bigEndian: idHeader.length))
|
||||
guard idLen >= MemoryLayout<CSHeader>.size else {
|
||||
os_log(.error, "PortalCam extension: invalid identity length %d for PID %d", idLen, pid)
|
||||
return false
|
||||
}
|
||||
var idBuf = [UInt8](repeating: 0, count: idLen)
|
||||
guard csops(pid, CS_OPS_IDENTITY, &idBuf, idLen) == 0 else {
|
||||
os_log(.error, "PortalCam extension: csops CS_OPS_IDENTITY failed for PID %d", pid)
|
||||
return false
|
||||
}
|
||||
let identity = String(cString: Array(idBuf[MemoryLayout<CSHeader>.size...]))
|
||||
|
||||
// 3. Check Team Identifier
|
||||
let CS_OPS_TEAMID: UInt32 = 14
|
||||
var teamHeader = CSHeader()
|
||||
let teamHeaderRet = csops(pid, CS_OPS_TEAMID, &teamHeader, MemoryLayout<CSHeader>.size)
|
||||
guard teamHeaderRet == 0 || errno == ERANGE else {
|
||||
os_log(.error, "PortalCam extension: csops CS_OPS_TEAMID header failed for PID %d: errno %d", pid, errno)
|
||||
return false
|
||||
}
|
||||
let teamLen = Int(UInt32(bigEndian: teamHeader.length))
|
||||
guard teamLen >= MemoryLayout<CSHeader>.size else {
|
||||
os_log(.error, "PortalCam extension: invalid team length %d for PID %d", teamLen, pid)
|
||||
return false
|
||||
}
|
||||
var teamBuf = [UInt8](repeating: 0, count: teamLen)
|
||||
guard csops(pid, CS_OPS_TEAMID, &teamBuf, teamLen) == 0 else {
|
||||
os_log(.error, "PortalCam extension: csops CS_OPS_TEAMID failed for PID %d", pid)
|
||||
return false
|
||||
}
|
||||
let teamID = String(cString: Array(teamBuf[MemoryLayout<CSHeader>.size...]))
|
||||
|
||||
os_log(.default, "PortalCam extension: verified client PID %d: identity='%{public}@', teamID='%{public}@', flags=0x%{public}x",
|
||||
pid, identity, teamID, status)
|
||||
|
||||
guard identity == "com.kovtash.portalcam", teamID == "ENT9X9U544" else {
|
||||
os_log(.error, "PortalCam extension: rejected client PID %d: identity mismatch ('%{public}@' != 'com.kovtash.portalcam') or team mismatch ('%{public}@' != 'ENT9X9U544')",
|
||||
pid, identity, teamID)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func startStream() throws {
|
||||
os_log(.default, "PortalCam extension: sink startStream called")
|
||||
_isStreaming = true
|
||||
_consumeQueue.async { [weak self] in
|
||||
self?.consumeNext()
|
||||
}
|
||||
}
|
||||
|
||||
func stopStream() throws {
|
||||
os_log(.default, "PortalCam extension: sink stopStream called")
|
||||
_isStreaming = false
|
||||
_activeClient = nil
|
||||
}
|
||||
|
||||
private func consumeNext() {
|
||||
guard _isStreaming, let stream = self.stream else { return }
|
||||
|
||||
guard let client = _activeClient ?? stream.streamingClients.first else {
|
||||
// No client connected yet; wait briefly and check again
|
||||
_consumeQueue.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
|
||||
self?.consumeNext()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
stream.consumeSampleBuffer(from: client) { [weak self] sampleBuffer, sequenceNumber, discontinuity, hasMore, error in
|
||||
guard let self = self, self._isStreaming else { return }
|
||||
|
||||
if let error = error {
|
||||
os_log(.default, "PortalCam extension: sink consume error: %{public}@", error.localizedDescription)
|
||||
self._consumeQueue.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
|
||||
self?.consumeNext()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let sampleBuffer = sampleBuffer {
|
||||
self.deviceSource?.deliverSinkSampleBuffer(sampleBuffer)
|
||||
|
||||
let pts = sampleBuffer.presentationTimeStamp
|
||||
let nano = pts.isValid && pts.seconds > 0 ? UInt64(pts.seconds * Double(NSEC_PER_SEC)) : mach_absolute_time()
|
||||
let output = CMIOExtensionScheduledOutput(sequenceNumber: sequenceNumber, hostTimeInNanoseconds: nano)
|
||||
stream.notifyScheduledOutputChanged(output)
|
||||
|
||||
// Pull next buffer immediately
|
||||
self._consumeQueue.async { [weak self] in
|
||||
self?.consumeNext()
|
||||
}
|
||||
} else {
|
||||
// Queue is empty, wait half a frame duration (~16ms)
|
||||
self._consumeQueue.asyncAfter(deadline: .now() + .milliseconds(16)) { [weak self] in
|
||||
self?.consumeNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider Source
|
||||
|
||||
class CamExtensionProviderSource: NSObject, CMIOExtensionProviderSource {
|
||||
|
||||
private(set) var provider: CMIOExtensionProvider!
|
||||
private var deviceSource: CamExtensionDeviceSource!
|
||||
|
||||
init(clientQueue: DispatchQueue?) {
|
||||
super.init()
|
||||
|
||||
provider = CMIOExtensionProvider(source: self, clientQueue: clientQueue)
|
||||
deviceSource = CamExtensionDeviceSource(localizedName: "PortalCam")
|
||||
|
||||
do {
|
||||
try provider.addDevice(deviceSource.device)
|
||||
} catch {
|
||||
fatalError("Failed to add device: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func connect(to client: CMIOExtensionClient) throws {
|
||||
// Client connected
|
||||
}
|
||||
|
||||
func disconnect(from client: CMIOExtensionClient) {
|
||||
// Client disconnected
|
||||
}
|
||||
|
||||
var availableProperties: Set<CMIOExtensionProperty> {
|
||||
return [.providerManufacturer]
|
||||
}
|
||||
|
||||
func providerProperties(forProperties properties: Set<CMIOExtensionProperty>) throws -> CMIOExtensionProviderProperties {
|
||||
let providerProperties = CMIOExtensionProviderProperties(dictionary: [:])
|
||||
if properties.contains(.providerManufacturer) {
|
||||
providerProperties.manufacturer = "PortalCam"
|
||||
}
|
||||
return providerProperties
|
||||
}
|
||||
|
||||
func setProviderProperties(_ providerProperties: CMIOExtensionProviderProperties) throws {
|
||||
// Settable properties
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user