Init
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(TeamIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CMIOExtension</key>
|
||||
<dict>
|
||||
<key>CMIOExtensionMachServiceName</key>
|
||||
<string>$(TeamIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,225 @@
|
||||
import Foundation
|
||||
import VideoToolbox
|
||||
import CoreImage
|
||||
import IOSurface
|
||||
import os.log
|
||||
|
||||
final class NativePortalStream: NSObject, URLSessionDataDelegate {
|
||||
private var session: URLSession!
|
||||
private var task: URLSessionDataTask?
|
||||
private var pending = Data()
|
||||
private let decoder = H264Decoder()
|
||||
private var dataCallbacks = 0
|
||||
var onFrame: ((CVPixelBuffer) -> Void)?
|
||||
var onStatus: ((String) -> Void)?
|
||||
|
||||
func start(host: String, token: String? = PortalAuth.token) {
|
||||
stop()
|
||||
dataCallbacks = 0
|
||||
decoder.onFrame = { [weak self] b in self?.onFrame?(b) }
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.timeoutIntervalForRequest = .greatestFiniteMagnitude
|
||||
cfg.timeoutIntervalForResource = 7 * 24 * 60 * 60
|
||||
session = URLSession(configuration: cfg, delegate: self, delegateQueue: OperationQueue())
|
||||
guard let u = URL(string: "https://\(host)/video.h264") else {
|
||||
onStatus?("Invalid Portal address")
|
||||
return
|
||||
}
|
||||
onStatus?("Connecting native H.264 (HTTPS)…")
|
||||
var req = URLRequest(url: u)
|
||||
if let token {
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
task = session.dataTask(with: req)
|
||||
task?.resume()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
session?.invalidateAndCancel()
|
||||
session = nil
|
||||
pending.removeAll()
|
||||
decoder.stop()
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
PortalTlsPinning.evaluate(challenge: challenge, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
||||
os_log(.default, "PortalCam stream: response %{public}@", String(describing: response))
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
dataCallbacks += 1
|
||||
if dataCallbacks <= 3 {
|
||||
os_log(.default, "PortalCam stream: received %{public}d bytes", data.count)
|
||||
}
|
||||
pending.append(data)
|
||||
consume()
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error {
|
||||
onStatus?("Stream stopped: \(error.localizedDescription)")
|
||||
} else {
|
||||
onStatus?("Stream completed")
|
||||
}
|
||||
}
|
||||
|
||||
private func consume() {
|
||||
while let first = startCode(in: pending, from: 0) {
|
||||
guard let next = startCode(in: pending, from: first.0 + first.1) else { break }
|
||||
let sc = next.0
|
||||
let nal = pending[(first.0 + first.1)..<sc]
|
||||
decoder.decode(nal: Data(nal))
|
||||
pending.removeSubrange(0..<sc)
|
||||
}
|
||||
if pending.count > 2 * 1024 * 1024 {
|
||||
pending = pending.suffix(256 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
private func startCode(in d: Data, from: Int) -> (Int, Int)? {
|
||||
if d.count < 4 || from >= d.count { return nil }
|
||||
for i in from..<(d.count - 2) {
|
||||
if d[i] == 0 && d[i+1] == 0 && d[i+2] == 1 { return (i, 3) }
|
||||
if i + 3 < d.count && d[i] == 0 && d[i+1] == 0 && d[i+2] == 0 && d[i+3] == 1 { return (i, 4) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private final class H264Decoder {
|
||||
private var sps = Data(), pps = Data(), session: VTDecompressionSession?, format: CMVideoFormatDescription?
|
||||
var onFrame: ((CVPixelBuffer) -> Void)?
|
||||
private var decodedFrames = 0
|
||||
|
||||
func stop() {
|
||||
if let session { VTDecompressionSessionInvalidate(session) }
|
||||
session = nil
|
||||
format = nil
|
||||
}
|
||||
|
||||
func decode(nal: Data) {
|
||||
guard let type = nal.first.map({ $0 & 0x1f }) else { return }
|
||||
if type == 7 {
|
||||
os_log(.default, "PortalCam decoder: SPS %{public}d bytes", nal.count)
|
||||
sps = nal
|
||||
rebuild()
|
||||
return
|
||||
}
|
||||
if type == 8 {
|
||||
os_log(.default, "PortalCam decoder: PPS %{public}d bytes", nal.count)
|
||||
pps = nal
|
||||
rebuild()
|
||||
return
|
||||
}
|
||||
guard (type == 1 || type == 5), let format else { return }
|
||||
var size = UInt32(nal.count).bigEndian
|
||||
var avcc = Data(bytes: &size, count: 4)
|
||||
avcc.append(nal)
|
||||
var block: CMBlockBuffer?
|
||||
avcc.withUnsafeBytes { raw in
|
||||
_ = CMBlockBufferCreateWithMemoryBlock(
|
||||
allocator: kCFAllocatorDefault,
|
||||
memoryBlock: UnsafeMutableRawPointer(mutating: raw.baseAddress!),
|
||||
blockLength: raw.count,
|
||||
blockAllocator: kCFAllocatorNull,
|
||||
customBlockSource: nil,
|
||||
offsetToData: 0,
|
||||
dataLength: raw.count,
|
||||
flags: 0,
|
||||
blockBufferOut: &block
|
||||
)
|
||||
}
|
||||
guard let block else { return }
|
||||
var sample: CMSampleBuffer?
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: CMTime(value: 1, timescale: 30),
|
||||
presentationTimeStamp: CMClockGetTime(CMClockGetHostTimeClock()),
|
||||
decodeTimeStamp: .invalid
|
||||
)
|
||||
var sizes = [avcc.count]
|
||||
_ = CMSampleBufferCreateReady(
|
||||
allocator: kCFAllocatorDefault,
|
||||
dataBuffer: block,
|
||||
formatDescription: format,
|
||||
sampleCount: 1,
|
||||
sampleTimingEntryCount: 1,
|
||||
sampleTimingArray: &timing,
|
||||
sampleSizeEntryCount: 1,
|
||||
sampleSizeArray: &sizes,
|
||||
sampleBufferOut: &sample
|
||||
)
|
||||
if let sample {
|
||||
let rc = VTDecompressionSessionDecodeFrame(
|
||||
session!,
|
||||
sampleBuffer: sample,
|
||||
flags: [],
|
||||
frameRefcon: Unmanaged.passUnretained(self).toOpaque(),
|
||||
infoFlagsOut: nil
|
||||
)
|
||||
if rc != noErr {
|
||||
os_log(.error, "PortalCam decoder: decode returned %{public}d", rc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuild() {
|
||||
guard !sps.isEmpty, !pps.isEmpty else { return }
|
||||
stop()
|
||||
var f: CMVideoFormatDescription?
|
||||
let rc: OSStatus = sps.withUnsafeBytes { sr in
|
||||
pps.withUnsafeBytes { pr in
|
||||
var ps = [sr.bindMemory(to: UInt8.self).baseAddress!, pr.bindMemory(to: UInt8.self).baseAddress!]
|
||||
var lens = [sps.count, pps.count]
|
||||
return CMVideoFormatDescriptionCreateFromH264ParameterSets(
|
||||
allocator: kCFAllocatorDefault,
|
||||
parameterSetCount: 2,
|
||||
parameterSetPointers: &ps,
|
||||
parameterSetSizes: &lens,
|
||||
nalUnitHeaderLength: 4,
|
||||
formatDescriptionOut: &f
|
||||
)
|
||||
}
|
||||
}
|
||||
guard rc == noErr, let f else {
|
||||
os_log(.error, "PortalCam decoder: format creation failed %{public}d", rc)
|
||||
return
|
||||
}
|
||||
os_log(.default, "PortalCam decoder: format ready")
|
||||
format = f
|
||||
let callback: VTDecompressionOutputCallback = { refCon, _, status, _, image, _, _ in
|
||||
if status == noErr, let image, let refCon {
|
||||
let decoder = Unmanaged<H264Decoder>.fromOpaque(refCon).takeUnretainedValue()
|
||||
decoder.decodedFrames += 1
|
||||
if decoder.decodedFrames <= 3 {
|
||||
os_log(.default, "PortalCam decoder: frame decoded (#%{public}d)", decoder.decodedFrames)
|
||||
}
|
||||
decoder.onFrame?(image)
|
||||
}
|
||||
}
|
||||
var cb = VTDecompressionOutputCallbackRecord(
|
||||
decompressionOutputCallback: callback,
|
||||
decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
var attrs: CFDictionary = [
|
||||
kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [:]
|
||||
] as CFDictionary
|
||||
var ds: VTDecompressionSession?
|
||||
let rc2 = VTDecompressionSessionCreate(
|
||||
allocator: kCFAllocatorDefault,
|
||||
formatDescription: f,
|
||||
decoderSpecification: nil,
|
||||
imageBufferAttributes: attrs,
|
||||
outputCallback: &cb,
|
||||
decompressionSessionOut: &ds
|
||||
)
|
||||
os_log(.default, "PortalCam decoder: session create %{public}d", rc2)
|
||||
session = ds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import os.log
|
||||
|
||||
/// Authentication shared with the containing app through the Keychain access group.
|
||||
/// The extension accesses the bearer token and pinned certificate fingerprint stored by the app.
|
||||
enum PortalAuth {
|
||||
static let suite = "ENT9X9U544.com.kovtash.portalcam"
|
||||
static let accessGroup = "ENT9X9U544.com.kovtash.portalcam"
|
||||
static let service = "com.kovtash.portalcam.auth"
|
||||
static let tokenKey = "portalAuthToken"
|
||||
static let certKey = "portalPinnedCertSha256"
|
||||
|
||||
static var token: String? {
|
||||
return readItem(account: tokenKey)
|
||||
}
|
||||
|
||||
static var pinnedCertSha256: String? {
|
||||
return readItem(account: certKey)
|
||||
}
|
||||
|
||||
private static func readItem(account: String) -> String? {
|
||||
// 1. Try modern Data Protection Keychain with explicit access group
|
||||
let dpQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecUseDataProtectionKeychain as String: true
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let dpStatus = SecItemCopyMatching(dpQuery as CFDictionary, &item)
|
||||
if dpStatus == errSecSuccess, let data = item as? Data,
|
||||
let value = String(data: data, encoding: .utf8) {
|
||||
os_log(.default, "PortalCam extension: %{public}@ found in DP Keychain with access group", account)
|
||||
return value
|
||||
}
|
||||
|
||||
// 2. Try Data Protection Keychain without explicit access group (matches all groups in entitlement)
|
||||
let defaultGroupQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecUseDataProtectionKeychain as String: true
|
||||
]
|
||||
var defaultItem: CFTypeRef?
|
||||
let defaultStatus = SecItemCopyMatching(defaultGroupQuery as CFDictionary, &defaultItem)
|
||||
if defaultStatus == errSecSuccess, let data = defaultItem as? Data,
|
||||
let value = String(data: data, encoding: .utf8) {
|
||||
os_log(.default, "PortalCam extension: %{public}@ found in DP Keychain default group", account)
|
||||
return value
|
||||
}
|
||||
|
||||
// 3. Try legacy file-based keychain
|
||||
let legacyQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var legacyItem: CFTypeRef?
|
||||
let legacyStatus = SecItemCopyMatching(legacyQuery as CFDictionary, &legacyItem)
|
||||
if legacyStatus == errSecSuccess, let data = legacyItem as? Data,
|
||||
let value = String(data: data, encoding: .utf8) {
|
||||
os_log(.default, "PortalCam extension: %{public}@ found in Legacy Keychain", account)
|
||||
return value
|
||||
}
|
||||
|
||||
// 4. Compatibility fallback: App Group UserDefaults
|
||||
if let value = UserDefaults(suiteName: suite)?.string(forKey: account) {
|
||||
os_log(.default, "PortalCam extension: %{public}@ found in App Group fallback", account)
|
||||
return value
|
||||
}
|
||||
|
||||
os_log(.default, "PortalCam extension: %{public}@ unavailable (DP status: %{public}d, default status: %{public}d, legacy status: %{public}d)", account, dpStatus, defaultStatus, legacyStatus)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// PortalTlsPinning.swift
|
||||
// CamExtension
|
||||
//
|
||||
// TLS Certificate Pinning for Camera Extension.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
import CryptoKit
|
||||
import os.log
|
||||
|
||||
public enum PortalTlsPinning {
|
||||
public static func extractLeafCert(from serverTrust: SecTrust) -> SecCertificate? {
|
||||
if #available(macOS 12.0, *) {
|
||||
if let chain = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate], !chain.isEmpty {
|
||||
return chain[0]
|
||||
}
|
||||
}
|
||||
let count = SecTrustGetCertificateCount(serverTrust)
|
||||
guard count > 0 else { return nil }
|
||||
return SecTrustGetCertificateAtIndex(serverTrust, 0)
|
||||
}
|
||||
|
||||
public static func computeCertSha256(cert: SecCertificate) -> (data: Data, hex: String) {
|
||||
let certDer = SecCertificateCopyData(cert) as Data
|
||||
let digest = SHA256.hash(data: certDer)
|
||||
let data = Data(digest)
|
||||
let hex = data.map { String(format: "%02x", $0) }.joined()
|
||||
return (data, hex)
|
||||
}
|
||||
|
||||
public static func evaluate(
|
||||
challenge: URLAuthenticationChallenge,
|
||||
pinnedFingerprint: String? = nil,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let serverTrust = challenge.protectionSpace.serverTrust else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let cert = extractLeafCert(from: serverTrust) else {
|
||||
os_log(.error, "PortalCam Extension TLS: No certificate found in server trust chain")
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let (_, certHashHex) = computeCertSha256(cert: cert)
|
||||
let targetFingerprint = pinnedFingerprint ?? PortalAuth.pinnedCertSha256
|
||||
|
||||
guard let pinned = targetFingerprint, !pinned.isEmpty else {
|
||||
os_log(.error, "PortalCam Extension TLS: No pinned certificate configured; rejecting connection")
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard certHashHex.caseInsensitiveCompare(pinned) == .orderedSame else {
|
||||
os_log(.error, "PortalCam Extension TLS Pinning Mismatch! Expected: %{public}@, Got: %{public}@", pinned, certHashHex)
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
|
||||
completionHandler(.useCredential, URLCredential(trust: serverTrust))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// main.swift
|
||||
// CamExtension
|
||||
//
|
||||
// Created by Vlad on 9/12/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreMediaIO
|
||||
|
||||
let providerSource = CamExtensionProviderSource(clientQueue: nil)
|
||||
CMIOExtensionProvider.startService(provider: providerSource.provider)
|
||||
|
||||
CFRunLoopRun()
|
||||
Reference in New Issue
Block a user