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()
|
||||
@@ -0,0 +1,546 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2839601E305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 28396014305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
283960323055440000E4C494 /* PortalKit in Frameworks */ = {isa = PBXBuildFile; productRef = 283960313055440000E4C494 /* PortalKit */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
2839601C305543F400E4C494 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 28395FFA305543C000E4C494 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 28396013305543F400E4C494;
|
||||
remoteInfo = CamExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
28396023305543F400E4C494 /* Embed System Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(SYSTEM_EXTENSIONS_FOLDER_PATH)";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
2839601E305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension in Embed System Extensions */,
|
||||
);
|
||||
name = "Embed System Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
28396002305543C000E4C494 /* PortalCam.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PortalCam.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
28396014305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension */ = {isa = PBXFileReference; explicitFileType = "wrapper.system-extension"; includeInIndex = 0; path = "com.kovtash.portalcam.camera-extension.systemextension"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
2839601F305543F400E4C494 /* Exceptions for "CamExtension" folder in "CamExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 28396013305543F400E4C494 /* CamExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
28396004305543C000E4C494 /* PortalCam */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = PortalCam;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
28396015305543F400E4C494 /* CamExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
2839601F305543F400E4C494 /* Exceptions for "CamExtension" folder in "CamExtension" target */,
|
||||
);
|
||||
path = CamExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
28395FFF305543C000E4C494 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
283960323055440000E4C494 /* PortalKit in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
28396011305543F400E4C494 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
28395FF9305543C000E4C494 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
28396004305543C000E4C494 /* PortalCam */,
|
||||
28396015305543F400E4C494 /* CamExtension */,
|
||||
28396003305543C000E4C494 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
28396003305543C000E4C494 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
28396002305543C000E4C494 /* PortalCam.app */,
|
||||
28396014305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
28396001305543C000E4C494 /* PortalCam */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 2839600D305543C100E4C494 /* Build configuration list for PBXNativeTarget "PortalCam" */;
|
||||
buildPhases = (
|
||||
28395FFE305543C000E4C494 /* Sources */,
|
||||
28395FFF305543C000E4C494 /* Frameworks */,
|
||||
28396000305543C000E4C494 /* Resources */,
|
||||
28396023305543F400E4C494 /* Embed System Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
2839601D305543F400E4C494 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
28396004305543C000E4C494 /* PortalCam */,
|
||||
);
|
||||
name = PortalCam;
|
||||
packageProductDependencies = (
|
||||
283960313055440000E4C494 /* PortalKit */,
|
||||
);
|
||||
productName = PortalCam;
|
||||
productReference = 28396002305543C000E4C494 /* PortalCam.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
28396013305543F400E4C494 /* CamExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 28396020305543F400E4C494 /* Build configuration list for PBXNativeTarget "CamExtension" */;
|
||||
buildPhases = (
|
||||
28396010305543F400E4C494 /* Sources */,
|
||||
28396011305543F400E4C494 /* Frameworks */,
|
||||
28396012305543F400E4C494 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
28396015305543F400E4C494 /* CamExtension */,
|
||||
);
|
||||
name = CamExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = CamExtension;
|
||||
productReference = 28396014305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension */;
|
||||
productType = "com.apple.product-type.system-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
28395FFA305543C000E4C494 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2660;
|
||||
LastUpgradeCheck = 2660;
|
||||
TargetAttributes = {
|
||||
28396001305543C000E4C494 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
28396013305543F400E4C494 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 28395FFD305543C000E4C494 /* Build configuration list for PBXProject "PortalCam" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 28395FF9305543C000E4C494;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
283960303055440000E4C494 /* XCLocalSwiftPackageReference "../PortalKit" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 28396003305543C000E4C494 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
28396001305543C000E4C494 /* PortalCam */,
|
||||
28396013305543F400E4C494 /* CamExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
28396000305543C000E4C494 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
28396012305543F400E4C494 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
28395FFE305543C000E4C494 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
28396010305543F400E4C494 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
2839601D305543F400E4C494 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 28396013305543F400E4C494 /* CamExtension */;
|
||||
targetProxy = 2839601C305543F400E4C494 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
2839600B305543C100E4C494 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = ENT9X9U544;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
2839600C305543C100E4C494 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = ENT9X9U544;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
2839600E305543C100E4C494 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = PortalCam/PortalCam.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = ENT9X9U544;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSUIElement = YES;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "PortalCam requires camera access to stream to virtual camera devices.";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "PortalCam connects to your Portal TV on the local network for video, audio, and camera control.";
|
||||
INFOPLIST_KEY_NSBonjourServices = "_portalcam._tcp";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kovtash.portalcam;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
2839600F305543C100E4C494 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = PortalCam/PortalCam.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = ENT9X9U544;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSUIElement = YES;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "PortalCam requires camera access to stream to virtual camera devices.";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "PortalCam connects to your Portal TV on the local network for video, audio, and camera control.";
|
||||
INFOPLIST_KEY_NSBonjourServices = "_portalcam._tcp";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kovtash.portalcam;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
28396021305543F400E4C494 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = CamExtension/CamExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = ENT9X9U544;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = CamExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = CamExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSSystemExtensionUsageDescription = SYSTEM_EXTENSION_USAGE_DESCRIPTION;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.kovtash.portalcam.camera-extension";
|
||||
PRODUCT_NAME = "$(inherited)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
28396022305543F400E4C494 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = CamExtension/CamExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = ENT9X9U544;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = CamExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = CamExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSSystemExtensionUsageDescription = SYSTEM_EXTENSION_USAGE_DESCRIPTION;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.kovtash.portalcam.camera-extension";
|
||||
PRODUCT_NAME = "$(inherited)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
28395FFD305543C000E4C494 /* Build configuration list for PBXProject "PortalCam" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
2839600B305543C100E4C494 /* Debug */,
|
||||
2839600C305543C100E4C494 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
2839600D305543C100E4C494 /* Build configuration list for PBXNativeTarget "PortalCam" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
2839600E305543C100E4C494 /* Debug */,
|
||||
2839600F305543C100E4C494 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
28396020305543F400E4C494 /* Build configuration list for PBXNativeTarget "CamExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
28396021305543F400E4C494 /* Debug */,
|
||||
28396022305543F400E4C494 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
283960303055440000E4C494 /* XCLocalSwiftPackageReference "../PortalKit" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = ../PortalKit;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
283960313055440000E4C494 /* PortalKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 283960303055440000E4C494 /* XCLocalSwiftPackageReference "../PortalKit" */;
|
||||
productName = PortalKit;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 28395FFA305543C000E4C494 /* Project object */;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
<key>com.apple.developer.system-extension.install</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.system-extension.types</key>
|
||||
<array>
|
||||
<string>com.apple.system_extension.camera-device</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,804 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// PortalCam
|
||||
//
|
||||
// Menubar popup: address → PIN pairing → live preview + mode controls.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import Combine
|
||||
import PortalKit
|
||||
|
||||
enum PortalUIPhase: Equatable {
|
||||
case setup
|
||||
case enterPin
|
||||
case main
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject private var model: PortalReceiverModel
|
||||
|
||||
/// Content width of the menubar popup (preview spans this edge-to-edge inside padding).
|
||||
private let panelWidth: CGFloat = 336
|
||||
private let previewAspect: CGFloat = 16.0 / 9.0
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Group {
|
||||
switch model.phase {
|
||||
case .setup:
|
||||
setupView
|
||||
case .enterPin:
|
||||
pinView
|
||||
case .main:
|
||||
mainView
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
|
||||
Divider()
|
||||
.padding(.bottom, 4)
|
||||
|
||||
footer
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 10)
|
||||
.frame(width: panelWidth)
|
||||
.fixedSize(horizontal: true, vertical: true)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(model.status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if model.isVirtualCamActive {
|
||||
Label("Virtual Cam", systemImage: "video.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Button("Quit") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
private var setupView: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("PortalCam")
|
||||
.font(.headline)
|
||||
Text(model.browser.statusMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if !model.browser.portals.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(model.browser.portals) { portal in
|
||||
Button {
|
||||
model.selectDiscoveredPortal(portal)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(portal.name)
|
||||
.foregroundStyle(.primary)
|
||||
Text(portal.host)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if model.hostWithoutPort == portal.host {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if portal.id != model.browser.portals.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField("Or type address (no port)", text: $model.manualHost)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { model.beginPairing() }
|
||||
.onChange(of: model.manualHost) { _, value in
|
||||
model.applyManualHost(value)
|
||||
}
|
||||
|
||||
Button {
|
||||
model.beginPairing()
|
||||
} label: {
|
||||
Text(model.isBusy ? "Connecting…" : "Connect")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.disabled(model.isBusy || model.hostWithoutPort.isEmpty)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.onAppear { model.browser.start() }
|
||||
.onDisappear { model.browser.stop() }
|
||||
}
|
||||
|
||||
private var pinView: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Enter pairing PIN")
|
||||
.font(.headline)
|
||||
Text("Shown on the Portal TV screen.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(model.hostWithoutPort)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("6-digit PIN", text: $model.pin)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { model.submitPin() }
|
||||
HStack {
|
||||
Button("Back") {
|
||||
model.cancelPairing()
|
||||
}
|
||||
.disabled(model.isBusy)
|
||||
Button {
|
||||
model.submitPin()
|
||||
} label: {
|
||||
Text(model.isBusy ? "Verifying…" : "Pair")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.disabled(model.isBusy || model.pin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mainView: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text(model.connectedPortalTitle)
|
||||
.font(model.resolvedPortalName == nil ? .caption.monospaced() : .caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.help(model.hostWithoutPort)
|
||||
Spacer()
|
||||
Image(systemName: model.isConnected ? "lock.fill" : "lock.open")
|
||||
.foregroundStyle(model.isConnected ? .green : .secondary)
|
||||
.help(model.isConnected ? "Connected & pinned" : "Connecting…")
|
||||
Button("Unpair") {
|
||||
model.unpair()
|
||||
}
|
||||
.font(.caption)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
|
||||
previewPane
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(previewAspect, contentMode: .fit)
|
||||
// Bleed past horizontal content padding so the preview is edge-to-edge.
|
||||
.padding(.horizontal, -14)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
StretchySegmentedControl(
|
||||
options: PortalCameraMode.allCases.map(\.title),
|
||||
selection: Binding(
|
||||
get: { PortalCameraMode.allCases.firstIndex(of: model.selectedMode) ?? 0 },
|
||||
set: { index in
|
||||
let mode = PortalCameraMode.allCases[index]
|
||||
model.selectMode(mode)
|
||||
}
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 28)
|
||||
|
||||
if model.selectedMode == .fixed {
|
||||
fixedControls
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private var fixedControls: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
labeledSlider("X", value: fixedBinding(\.x))
|
||||
labeledSlider("Y", value: fixedBinding(\.y))
|
||||
labeledSlider("Scale", value: fixedBinding(\.scale), range: 0.1...1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
/// Slider writes go through the model so crop updates can be throttled.
|
||||
private func fixedBinding(_ keyPath: ReferenceWritableKeyPath<PortalReceiverModel, Double>) -> Binding<Double> {
|
||||
Binding(
|
||||
get: { model[keyPath: keyPath] },
|
||||
set: { model.setFixedCropParameter(keyPath, to: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var previewPane: some View {
|
||||
if let image = model.image {
|
||||
Image(decorative: image, scale: 1)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
} else {
|
||||
Color.black
|
||||
.overlay {
|
||||
Text(model.isConnected ? "Waiting for video…" : "Connecting…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func labeledSlider(
|
||||
_ title: String,
|
||||
value: Binding<Double>,
|
||||
range: ClosedRange<Double> = 0...1
|
||||
) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.frame(width: 44, alignment: .leading)
|
||||
Slider(value: value, in: range)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PortalCameraMode: String, CaseIterable, Identifiable {
|
||||
case defaultAuto = "DefaultAuto"
|
||||
case desk = "Desk"
|
||||
case meeting = "Meeting"
|
||||
case fixed = "Fixed"
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .defaultAuto: return "Auto"
|
||||
case .desk: return "Desk"
|
||||
case .meeting: return "Meeting"
|
||||
case .fixed: return "Fixed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-width macOS segmented control — SwiftUI's `.segmented` picker won't stretch.
|
||||
private struct StretchySegmentedControl: NSViewRepresentable {
|
||||
let options: [String]
|
||||
@Binding var selection: Int
|
||||
|
||||
final class FillControl: NSSegmentedControl {
|
||||
override func layout() {
|
||||
super.layout()
|
||||
if #available(macOS 13.0, *) {
|
||||
segmentDistribution = .fillEqually
|
||||
} else if segmentCount > 0, bounds.width > 0 {
|
||||
let width = bounds.width / CGFloat(segmentCount)
|
||||
for i in 0..<segmentCount {
|
||||
setWidth(width, forSegment: i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> FillControl {
|
||||
let control = FillControl(
|
||||
labels: options,
|
||||
trackingMode: .selectOne,
|
||||
target: context.coordinator,
|
||||
action: #selector(Coordinator.changed(_:))
|
||||
)
|
||||
control.segmentStyle = .rounded
|
||||
control.selectedSegment = selection
|
||||
if #available(macOS 13.0, *) {
|
||||
control.segmentDistribution = .fillEqually
|
||||
}
|
||||
control.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
control.setContentCompressionResistancePriority(.fittingSizeCompression, for: .horizontal)
|
||||
return control
|
||||
}
|
||||
|
||||
func updateNSView(_ control: FillControl, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
if control.segmentCount != options.count {
|
||||
control.segmentCount = options.count
|
||||
for (i, title) in options.enumerated() {
|
||||
control.setLabel(title, forSegment: i)
|
||||
}
|
||||
}
|
||||
// Keep the thumb on the user's click; only sync when SwiftUI state differs
|
||||
// and suppress the action so programmatic updates don't re-fire the binding.
|
||||
if control.selectedSegment != selection, selection >= 0, selection < options.count {
|
||||
context.coordinator.isProgrammaticUpdate = true
|
||||
control.selectedSegment = selection
|
||||
context.coordinator.isProgrammaticUpdate = false
|
||||
}
|
||||
control.needsLayout = true
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
var parent: StretchySegmentedControl
|
||||
var isProgrammaticUpdate = false
|
||||
init(_ parent: StretchySegmentedControl) { self.parent = parent }
|
||||
|
||||
@objc func changed(_ sender: NSSegmentedControl) {
|
||||
guard !isProgrammaticUpdate else { return }
|
||||
let index = sender.selectedSegment
|
||||
guard index >= 0, parent.selection != index else { return }
|
||||
parent.selection = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PortalReceiverModel: ObservableObject {
|
||||
@Published var phase: PortalUIPhase
|
||||
/// Always `host:5654` for clients; UI edits use [manualHost] / discovery without port.
|
||||
@Published var host: String
|
||||
@Published var manualHost: String = ""
|
||||
@Published var pin = ""
|
||||
@Published var status = ""
|
||||
@Published var isBusy = false
|
||||
@Published var isConnected = false
|
||||
@Published var isVirtualCamActive = false
|
||||
@Published var image: CGImage?
|
||||
@Published var selectedMode: PortalCameraMode = .defaultAuto
|
||||
@Published var x = 0.5
|
||||
@Published var y = 0.5
|
||||
@Published var scale = 1.0
|
||||
|
||||
let browser = PortalBrowser()
|
||||
|
||||
var menuBarSymbolName: String {
|
||||
if phase == .main && isConnected { return "video.fill" }
|
||||
if phase == .main { return "video.badge.ellipsis" }
|
||||
return "video"
|
||||
}
|
||||
|
||||
var hostWithoutPort: String {
|
||||
Self.stripPort(host)
|
||||
}
|
||||
|
||||
/// mDNS service name for the paired host, if currently discovered on the LAN.
|
||||
var resolvedPortalName: String? {
|
||||
let target = Self.normalizedHost(hostWithoutPort)
|
||||
guard !target.isEmpty else { return nil }
|
||||
return browser.portals.first(where: { Self.normalizedHost($0.host) == target })?.name
|
||||
}
|
||||
|
||||
/// Prefer discovered portal name; fall back to IP / host.
|
||||
var connectedPortalTitle: String {
|
||||
resolvedPortalName ?? hostWithoutPort
|
||||
}
|
||||
|
||||
private let media = PortalMediaSession.shared
|
||||
private let context = CIContext(options: [.cacheIntermediates: false])
|
||||
private var browserBag: AnyCancellable?
|
||||
|
||||
private var isPanelVisible = false
|
||||
private var activePairingSession: PairingSession?
|
||||
private var unauthorizedHandled = false
|
||||
/** Bumped on every camera control call; stale responses are ignored. */
|
||||
private var cameraControlGeneration = 0
|
||||
private var isApplyingRemoteState = false
|
||||
private var lastFixedCropSentAt = Date.distantPast
|
||||
private var lastFixedCropEditedAt = Date.distantPast
|
||||
private var pendingFixedCropTask: Task<Void, Never>?
|
||||
private let fixedCropMinInterval: TimeInterval = 0.1
|
||||
private var stateEventsTask: Task<Void, Never>?
|
||||
|
||||
private var client: PortalClient {
|
||||
PortalClient(host: host)
|
||||
}
|
||||
|
||||
private var isPaired: Bool {
|
||||
PortalAuth.token != nil && PortalAuth.pinnedCertSha256 != nil
|
||||
}
|
||||
|
||||
init() {
|
||||
let savedHost = UserDefaults.standard.string(forKey: "portalHost") ?? ""
|
||||
let bare = Self.stripPort(savedHost)
|
||||
manualHost = bare
|
||||
host = bare.isEmpty ? "" : "\(bare):\(PortalEndpoints.port)"
|
||||
if PortalAuth.token != nil && PortalAuth.pinnedCertSha256 != nil && !bare.isEmpty {
|
||||
phase = .main
|
||||
status = "Ready"
|
||||
startCameraStateEvents()
|
||||
} else {
|
||||
phase = .setup
|
||||
status = "Select or enter a Portal"
|
||||
}
|
||||
browserBag = browser.objectWillChange.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
wireMediaSession()
|
||||
setupExtensionSink()
|
||||
}
|
||||
|
||||
func selectDiscoveredPortal(_ portal: DiscoveredPortal) {
|
||||
manualHost = portal.host
|
||||
host = "\(portal.host):\(PortalEndpoints.port)"
|
||||
status = "Selected \(portal.name)"
|
||||
}
|
||||
|
||||
func applyManualHost(_ value: String) {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let bare = Self.stripPort(trimmed)
|
||||
manualHost = trimmed
|
||||
host = bare.isEmpty ? "" : "\(bare):\(PortalEndpoints.port)"
|
||||
}
|
||||
|
||||
private static func stripPort(_ value: String) -> String {
|
||||
var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let pct = trimmed.firstIndex(of: "%") {
|
||||
trimmed = String(trimmed[..<pct])
|
||||
}
|
||||
guard let colon = trimmed.lastIndex(of: ":"),
|
||||
trimmed[trimmed.index(after: colon)...].allSatisfy(\.isNumber) else {
|
||||
return trimmed
|
||||
}
|
||||
return String(trimmed[..<colon])
|
||||
}
|
||||
|
||||
func panelDidAppear() {
|
||||
isPanelVisible = true
|
||||
if phase == .main {
|
||||
browser.start()
|
||||
acquireMedia(.uiPreview)
|
||||
startCameraStateEvents()
|
||||
}
|
||||
}
|
||||
|
||||
func panelDidDisappear() {
|
||||
isPanelVisible = false
|
||||
if phase == .main {
|
||||
browser.stop()
|
||||
}
|
||||
media.release(.uiPreview)
|
||||
image = nil
|
||||
// Keep SSE alive while paired so detached preview / virtual cam stay in sync.
|
||||
}
|
||||
|
||||
private static func normalizedHost(_ value: String) -> String {
|
||||
var h = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let pct = h.firstIndex(of: "%") {
|
||||
h = String(h[..<pct])
|
||||
}
|
||||
if h.hasPrefix("[") && h.hasSuffix("]") {
|
||||
h = String(h.dropFirst().dropLast())
|
||||
}
|
||||
return h.lowercased()
|
||||
}
|
||||
|
||||
func beginPairing() {
|
||||
applyManualHost(manualHost)
|
||||
let bare = hostWithoutPort
|
||||
guard !bare.isEmpty else {
|
||||
status = "Select or enter a Portal address"
|
||||
return
|
||||
}
|
||||
host = "\(bare):\(PortalEndpoints.port)"
|
||||
isBusy = true
|
||||
status = "Requesting pairing…"
|
||||
pin = ""
|
||||
activePairingSession = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let session = try await client.initiatePairing()
|
||||
self.activePairingSession = session
|
||||
self.phase = .enterPin
|
||||
self.status = "Enter the PIN shown on Portal TV"
|
||||
self.browser.stop()
|
||||
} catch {
|
||||
self.status = "Connect failed: \(error.localizedDescription)"
|
||||
}
|
||||
self.isBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
func cancelPairing() {
|
||||
activePairingSession = nil
|
||||
pin = ""
|
||||
phase = .setup
|
||||
status = "Select or enter a Portal"
|
||||
browser.start()
|
||||
}
|
||||
|
||||
func submitPin() {
|
||||
let cleanPin = pin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanPin.isEmpty else {
|
||||
status = "Enter the PIN"
|
||||
return
|
||||
}
|
||||
guard let session = activePairingSession else {
|
||||
status = "Session expired — tap Connect again"
|
||||
phase = .setup
|
||||
browser.start()
|
||||
return
|
||||
}
|
||||
|
||||
isBusy = true
|
||||
status = "Verifying…"
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await client.completePairing(session: session, pin: cleanPin)
|
||||
UserDefaults.standard.set(self.host, forKey: "portalHost")
|
||||
UserDefaults(suiteName: PortalAuth.suite)?.set(self.host, forKey: "portalHost")
|
||||
self.activePairingSession = nil
|
||||
self.pin = ""
|
||||
self.unauthorizedHandled = false
|
||||
self.phase = .main
|
||||
self.status = "Paired (\(result.pinnedCertSha256Hex.prefix(8))…)"
|
||||
self.acquireMedia(.uiPreview)
|
||||
self.startCameraStateEvents()
|
||||
} catch {
|
||||
self.status = "Pairing failed: \(error.localizedDescription)"
|
||||
}
|
||||
self.isBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
func unpair() {
|
||||
stopCameraStateEvents()
|
||||
media.releaseAll()
|
||||
ExtensionSinkWriter.shared.stopSink()
|
||||
isVirtualCamActive = false
|
||||
PortalAuth.clear()
|
||||
activePairingSession = nil
|
||||
pin = ""
|
||||
image = nil
|
||||
isConnected = false
|
||||
unauthorizedHandled = false
|
||||
phase = .setup
|
||||
status = "Unpaired — select or enter a Portal"
|
||||
browser.start()
|
||||
}
|
||||
|
||||
func selectMode(_ mode: PortalCameraMode) {
|
||||
guard mode != selectedMode else { return }
|
||||
// Keep the segment on the clicked mode while the request is in flight;
|
||||
// otherwise updateNSView snaps it back to the previous selectedMode.
|
||||
selectedMode = mode
|
||||
runCameraControl { try await self.client.setMode(mode.rawValue) }
|
||||
}
|
||||
|
||||
/// Slider edits for Fixed crop — local UI updates immediately, network at ≤10 Hz.
|
||||
func setFixedCropParameter(
|
||||
_ keyPath: ReferenceWritableKeyPath<PortalReceiverModel, Double>,
|
||||
to value: Double
|
||||
) {
|
||||
guard !isApplyingRemoteState else { return }
|
||||
self[keyPath: keyPath] = value
|
||||
selectedMode = .fixed
|
||||
lastFixedCropEditedAt = Date()
|
||||
scheduleFixedCropSend()
|
||||
}
|
||||
|
||||
func startCameraStateEvents() {
|
||||
guard isPaired else { return }
|
||||
stopCameraStateEvents()
|
||||
stateEventsTask = Task { @MainActor in
|
||||
let stream = self.client.cameraStateEvents()
|
||||
do {
|
||||
for try await state in stream {
|
||||
guard !Task.isCancelled else { break }
|
||||
self.applyCameraState(state)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// expected on stop / unpair
|
||||
} catch let error as PortalClientError {
|
||||
self.handleControlError(error)
|
||||
// Pin mismatch: stop reconnecting — keep the app up and show the error.
|
||||
if case .tlsPinningMismatch = error { return }
|
||||
} catch {
|
||||
let ns = error as NSError
|
||||
if ns.domain == NSURLErrorDomain {
|
||||
self.status = "Reconnecting…"
|
||||
} else {
|
||||
self.status = error.localizedDescription
|
||||
}
|
||||
}
|
||||
// Auto-reconnect while still paired (SSE drop / sleep).
|
||||
guard !Task.isCancelled, self.isPaired, self.phase == .main else { return }
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
guard !Task.isCancelled, self.isPaired else { return }
|
||||
self.startCameraStateEvents()
|
||||
}
|
||||
}
|
||||
|
||||
func stopCameraStateEvents() {
|
||||
stateEventsTask?.cancel()
|
||||
stateEventsTask = nil
|
||||
}
|
||||
|
||||
/// Sole UI update path for camera mode/config — driven by SSE `/control/events`.
|
||||
func applyCameraState(_ state: PortalCameraState) {
|
||||
isApplyingRemoteState = true
|
||||
defer { isApplyingRemoteState = false }
|
||||
|
||||
if let mode = PortalCameraMode(rawValue: state.mode), mode != selectedMode {
|
||||
selectedMode = mode
|
||||
}
|
||||
// Don't fight the slider while the user is dragging (or a trailing send is queued).
|
||||
let userEditingFixed = pendingFixedCropTask != nil
|
||||
|| Date().timeIntervalSince(lastFixedCropEditedAt) < 0.25
|
||||
if !userEditingFixed {
|
||||
if let cx = state.config.centerX, abs(cx - x) > 0.0005 { x = cx }
|
||||
if let cy = state.config.centerY, abs(cy - y) > 0.0005 { y = cy }
|
||||
if let s = state.config.scale, abs(s - scale) > 0.0005 { scale = s }
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleFixedCropSend() {
|
||||
let now = Date()
|
||||
let elapsed = now.timeIntervalSince(lastFixedCropSentAt)
|
||||
if elapsed >= fixedCropMinInterval {
|
||||
pendingFixedCropTask?.cancel()
|
||||
pendingFixedCropTask = nil
|
||||
sendFixedCropNow()
|
||||
return
|
||||
}
|
||||
pendingFixedCropTask?.cancel()
|
||||
let delay = fixedCropMinInterval - elapsed
|
||||
pendingFixedCropTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
self.pendingFixedCropTask = nil
|
||||
self.sendFixedCropNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func sendFixedCropNow() {
|
||||
lastFixedCropSentAt = Date()
|
||||
let x = self.x, y = self.y, scale = self.scale
|
||||
runCameraControl {
|
||||
try await self.client.setFixedCrop(x: x, y: y, scale: scale)
|
||||
}
|
||||
}
|
||||
|
||||
private func runCameraControl(_ operation: @escaping () async throws -> Void) {
|
||||
guard isPaired else { return }
|
||||
cameraControlGeneration += 1
|
||||
let generation = cameraControlGeneration
|
||||
Task {
|
||||
do {
|
||||
try await operation()
|
||||
// UI updates arrive via SSE — do not apply from the command response.
|
||||
} catch let error as PortalClientError {
|
||||
guard generation == self.cameraControlGeneration else { return }
|
||||
self.handleControlError(error)
|
||||
} catch {
|
||||
guard generation == self.cameraControlGeneration else { return }
|
||||
self.status = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleControlError(_ error: PortalClientError) {
|
||||
switch error {
|
||||
case .requestFailed(let code, _) where code == 401:
|
||||
handleUnauthorized()
|
||||
case .tlsPinningMismatch:
|
||||
// Hard-fail the request/stream only — never terminate the app.
|
||||
stopCameraStateEvents()
|
||||
media.releaseAll()
|
||||
isConnected = false
|
||||
image = nil
|
||||
status = error.localizedDescription
|
||||
default:
|
||||
status = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func setupExtensionSink() {
|
||||
let sinkWriter = ExtensionSinkWriter.shared
|
||||
sinkWriter.startMonitoring()
|
||||
|
||||
media.setFrameHandler(.virtualCamera) { buffer in
|
||||
ExtensionSinkWriter.shared.send(pixelBuffer: buffer)
|
||||
}
|
||||
|
||||
sinkWriter.onConsumerStreamStarted = { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self, self.phase == .main else { return }
|
||||
self.isVirtualCamActive = true
|
||||
self.acquireMedia(.virtualCamera)
|
||||
}
|
||||
}
|
||||
|
||||
sinkWriter.onConsumerStreamStopped = { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.isVirtualCamActive = false
|
||||
self.media.release(.virtualCamera)
|
||||
ExtensionSinkWriter.shared.stopSink()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wireMediaSession() {
|
||||
media.setFrameHandler(.uiPreview) { [weak self] buffer in
|
||||
guard let self else { return }
|
||||
let ci = CIImage(cvPixelBuffer: buffer)
|
||||
let cg = self.context.createCGImage(ci, from: ci.extent)
|
||||
Task { @MainActor in
|
||||
self.image = cg
|
||||
if !self.isConnected {
|
||||
self.isConnected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
media.onStatus = { [weak self] s in
|
||||
Task { @MainActor in
|
||||
self?.status = s
|
||||
}
|
||||
}
|
||||
media.onUnauthorized = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.handleUnauthorized()
|
||||
}
|
||||
}
|
||||
media.onConnected = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.isConnected = true
|
||||
self?.status = "Live"
|
||||
}
|
||||
}
|
||||
media.onDisconnected = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.isConnected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func acquireMedia(_ consumer: PortalMediaConsumer) {
|
||||
guard phase == .main, isPaired else { return }
|
||||
unauthorizedHandled = false
|
||||
UserDefaults.standard.set(host, forKey: "portalHost")
|
||||
UserDefaults(suiteName: PortalAuth.suite)?.set(host, forKey: "portalHost")
|
||||
if !media.hasConsumers {
|
||||
status = "Connecting…"
|
||||
}
|
||||
media.acquire(consumer, host: host, token: PortalAuth.token)
|
||||
}
|
||||
|
||||
private func handleUnauthorized() {
|
||||
guard !unauthorizedHandled else { return }
|
||||
unauthorizedHandled = true
|
||||
stopCameraStateEvents()
|
||||
media.releaseAll()
|
||||
ExtensionSinkWriter.shared.stopSink()
|
||||
isVirtualCamActive = false
|
||||
PortalAuth.clear()
|
||||
activePairingSession = nil
|
||||
pin = ""
|
||||
image = nil
|
||||
isConnected = false
|
||||
phase = .setup
|
||||
status = "Not authorized — credentials cleared. Pair again."
|
||||
browser.start()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environmentObject(PortalReceiverModel())
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//
|
||||
// ExtensionSinkWriter.swift
|
||||
// PortalCam
|
||||
//
|
||||
// Created by Vlad on 9/12/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreMedia
|
||||
import CoreMediaIO
|
||||
import CoreVideo
|
||||
import AVFoundation
|
||||
import os.log
|
||||
|
||||
final class ExtensionSinkWriter {
|
||||
static let shared = ExtensionSinkWriter()
|
||||
|
||||
private let targetDeviceUUID = "7B1E8C4B-8D2E-4A0D-9B95-8F5C1C7D9B11"
|
||||
private let log = Logger(subsystem: "com.kovtash.portalcam", category: "sinkWriter")
|
||||
private let ioQueue = DispatchQueue(label: "com.kovtash.portalcam.sinkwriter", qos: .userInteractive)
|
||||
|
||||
private(set) var deviceID: CMIODeviceID?
|
||||
private(set) var sinkStreamID: CMIOStreamID?
|
||||
private var queue: CMSimpleQueue?
|
||||
private var formatDescription: CMVideoFormatDescription?
|
||||
|
||||
private var isListening = false
|
||||
private var isStreamStarted = false
|
||||
|
||||
private var deviceListenerBlock: CMIOObjectPropertyListenerBlock?
|
||||
private var systemListenerBlock: CMIOObjectPropertyListenerBlock?
|
||||
|
||||
var onConsumerStreamStarted: (() -> Void)?
|
||||
var onConsumerStreamStopped: (() -> Void)?
|
||||
|
||||
private init() {}
|
||||
|
||||
func startMonitoring() {
|
||||
guard !isListening else { return }
|
||||
isListening = true
|
||||
|
||||
registerDarwinNotifications()
|
||||
registerSystemDeviceListener()
|
||||
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
guard let self = self else { return }
|
||||
self.log.info("Camera access authorization result: \(granted)")
|
||||
self.ioQueue.async {
|
||||
_ = self.findAndSetupDevice()
|
||||
}
|
||||
}
|
||||
|
||||
ioQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
_ = self.findAndSetupDevice()
|
||||
}
|
||||
}
|
||||
|
||||
func resetAndReconnect() {
|
||||
ioQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopSinkInternal()
|
||||
_ = self.findAndSetupDevice()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Discovery & Setup
|
||||
|
||||
@discardableResult
|
||||
private func findAndSetupDevice() -> Bool {
|
||||
guard let (dev, sinkID) = findDeviceAndSinkStream() else {
|
||||
return false
|
||||
}
|
||||
self.deviceID = dev
|
||||
self.sinkStreamID = sinkID
|
||||
attachDeviceListeners(for: dev)
|
||||
|
||||
// If device is already in use by a consumer app, connect immediately
|
||||
if isDeviceRunningSomewhere(dev) {
|
||||
log.info("PortalCam device is already running somewhere upon discovery")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onConsumerStreamStarted?()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func findDeviceAndSinkStream() -> (CMIODeviceID, CMIOStreamID)? {
|
||||
var address = CMIOObjectPropertyAddress(
|
||||
mSelector: CMIOObjectPropertySelector(kCMIOHardwarePropertyDevices),
|
||||
mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
|
||||
mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
|
||||
)
|
||||
|
||||
var size: UInt32 = 0
|
||||
let sizeStatus = CMIOObjectGetPropertyDataSize(CMIOObjectID(kCMIOObjectSystemObject), &address, 0, nil, &size)
|
||||
guard sizeStatus == noErr else {
|
||||
log.warning("findDevice: get size error \(sizeStatus)")
|
||||
return nil
|
||||
}
|
||||
|
||||
let count = Int(size) / MemoryLayout<CMIODeviceID>.size
|
||||
var devices = [CMIODeviceID](repeating: 0, count: count)
|
||||
let getStatus = CMIOObjectGetPropertyData(CMIOObjectID(kCMIOObjectSystemObject), &address, 0, nil, size, &size, &devices)
|
||||
guard getStatus == noErr else {
|
||||
log.warning("findDevice: get data error \(getStatus)")
|
||||
return nil
|
||||
}
|
||||
log.info("findDevice: found \(devices.count) CMIO devices")
|
||||
|
||||
for dev in devices {
|
||||
var uidAddress = CMIOObjectPropertyAddress(
|
||||
mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceUID),
|
||||
mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
|
||||
mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
|
||||
)
|
||||
var uidString: CFString = "" as CFString
|
||||
let uidSize = UInt32(MemoryLayout<CFString>.size)
|
||||
var used: UInt32 = 0
|
||||
|
||||
let status = withUnsafeMutablePointer(to: &uidString) { ptr in
|
||||
CMIOObjectGetPropertyData(dev, &uidAddress, 0, nil, uidSize, &used, ptr)
|
||||
}
|
||||
log.info("findDevice: dev \(dev), uid status \(status), uid \(uidString as String)")
|
||||
guard status == noErr, (uidString as String).caseInsensitiveCompare(targetDeviceUUID) == .orderedSame else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Found target device, inspect streams
|
||||
var streamAddr = CMIOObjectPropertyAddress(
|
||||
mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyStreams),
|
||||
mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
|
||||
mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
|
||||
)
|
||||
var streamSize: UInt32 = 0
|
||||
guard CMIOObjectGetPropertyDataSize(dev, &streamAddr, 0, nil, &streamSize) == noErr else {
|
||||
log.warning("findDevice: failed to get stream size for dev \(dev)")
|
||||
continue
|
||||
}
|
||||
|
||||
let streamCount = Int(streamSize) / MemoryLayout<CMIOStreamID>.size
|
||||
var streams = [CMIOStreamID](repeating: 0, count: streamCount)
|
||||
guard CMIOObjectGetPropertyData(dev, &streamAddr, 0, nil, streamSize, &streamSize, &streams) == noErr else {
|
||||
log.warning("findDevice: failed to get stream data for dev \(dev)")
|
||||
continue
|
||||
}
|
||||
log.info("findDevice: dev \(dev) has \(streamCount) streams: \(streams)")
|
||||
|
||||
for streamID in streams {
|
||||
var dirAddr = CMIOObjectPropertyAddress(
|
||||
mSelector: CMIOObjectPropertySelector(kCMIOStreamPropertyDirection),
|
||||
mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
|
||||
mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
|
||||
)
|
||||
var dir: UInt32 = 1
|
||||
let sz = UInt32(MemoryLayout<UInt32>.size)
|
||||
var dirUsed: UInt32 = 0
|
||||
let dirStatus = CMIOObjectGetPropertyData(streamID, &dirAddr, 0, nil, sz, &dirUsed, &dir)
|
||||
log.info("findDevice: stream \(streamID), dir \(dir), status \(dirStatus)")
|
||||
|
||||
// Direction 0 is sink (host -> device)
|
||||
if dirStatus == noErr && dir == 0 {
|
||||
return (dev, streamID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Sink Connection
|
||||
|
||||
private func ensureSinkConnected() -> Bool {
|
||||
if isStreamStarted, queue != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let (dev, sinkID) = (deviceID != nil && sinkStreamID != nil) ? (deviceID!, sinkStreamID!) : findDeviceAndSinkStream() else {
|
||||
log.warning("PortalCam sink stream not yet discovered")
|
||||
return false
|
||||
}
|
||||
self.deviceID = dev
|
||||
self.sinkStreamID = sinkID
|
||||
|
||||
var unmanagedQueue: Unmanaged<CMSimpleQueue>?
|
||||
let queueStatus = CMIOStreamCopyBufferQueue(sinkID, { _, _, _ in }, nil, &unmanagedQueue)
|
||||
guard queueStatus == noErr, let unmanagedQueue = unmanagedQueue else {
|
||||
log.error("CMIOStreamCopyBufferQueue failed: \(queueStatus)")
|
||||
self.deviceID = nil
|
||||
self.sinkStreamID = nil
|
||||
self.queue = nil
|
||||
return false
|
||||
}
|
||||
self.queue = unmanagedQueue.takeRetainedValue()
|
||||
|
||||
let startStatus = CMIODeviceStartStream(dev, sinkID)
|
||||
if startStatus != noErr {
|
||||
log.error("CMIODeviceStartStream failed: \(startStatus)")
|
||||
self.deviceID = nil
|
||||
self.sinkStreamID = nil
|
||||
self.queue = nil
|
||||
return false
|
||||
}
|
||||
|
||||
self.isStreamStarted = true
|
||||
log.info("Started CMIO sink stream dev=\(dev), sinkID=\(sinkID)")
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Frame Delivery
|
||||
|
||||
func send(pixelBuffer: CVPixelBuffer) {
|
||||
ioQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard self.ensureSinkConnected(), let queue = self.queue else { return }
|
||||
|
||||
if self.formatDescription == nil || !CMVideoFormatDescriptionMatchesImageBuffer(self.formatDescription!, imageBuffer: pixelBuffer) {
|
||||
var newDesc: CMVideoFormatDescription?
|
||||
let status = CMVideoFormatDescriptionCreateForImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: pixelBuffer,
|
||||
formatDescriptionOut: &newDesc
|
||||
)
|
||||
if status == noErr {
|
||||
self.formatDescription = newDesc
|
||||
}
|
||||
}
|
||||
guard let formatDesc = self.formatDescription else { return }
|
||||
|
||||
let hostTime = CMClockGetTime(CMClockGetHostTimeClock())
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: CMTime(value: 1, timescale: 30),
|
||||
presentationTimeStamp: hostTime,
|
||||
decodeTimeStamp: .invalid
|
||||
)
|
||||
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
let createStatus = CMSampleBufferCreateForImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: pixelBuffer,
|
||||
dataReady: true,
|
||||
makeDataReadyCallback: nil,
|
||||
refcon: nil,
|
||||
formatDescription: formatDesc,
|
||||
sampleTiming: &timing,
|
||||
sampleBufferOut: &sampleBuffer
|
||||
)
|
||||
|
||||
guard createStatus == noErr, let sampleBuffer = sampleBuffer else { return }
|
||||
|
||||
let unmanaged = Unmanaged.passRetained(sampleBuffer)
|
||||
let enqueueStatus = CMSimpleQueueEnqueue(queue, element: unmanaged.toOpaque())
|
||||
if enqueueStatus != noErr {
|
||||
// If queue is full or rejected, balance retain count
|
||||
unmanaged.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopSink() {
|
||||
ioQueue.async { [weak self] in
|
||||
self?.stopSinkInternal()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopSinkInternal() {
|
||||
if isStreamStarted, let dev = deviceID, let sinkID = sinkStreamID {
|
||||
CMIODeviceStopStream(dev, sinkID)
|
||||
log.info("Stopped CMIO sink stream")
|
||||
}
|
||||
isStreamStarted = false
|
||||
queue = nil
|
||||
formatDescription = nil
|
||||
}
|
||||
|
||||
// MARK: - Signaling & Listeners
|
||||
|
||||
private func registerDarwinNotifications() {
|
||||
guard let center = CFNotificationCenterGetDarwinNotifyCenter() else { return }
|
||||
let observerPtr = Unmanaged.passUnretained(self).toOpaque()
|
||||
|
||||
let callback: CFNotificationCallback = { _, observer, name, _, _ in
|
||||
guard let observer = observer, let name = name?.rawValue as? String else { return }
|
||||
let writer = Unmanaged<ExtensionSinkWriter>.fromOpaque(observer).takeUnretainedValue()
|
||||
DispatchQueue.main.async {
|
||||
writer.handleDarwinNotification(name)
|
||||
}
|
||||
}
|
||||
|
||||
CFNotificationCenterAddObserver(
|
||||
center,
|
||||
observerPtr,
|
||||
callback,
|
||||
"com.kovtash.portalcam.streamStarted" as CFString,
|
||||
nil,
|
||||
.deliverImmediately
|
||||
)
|
||||
CFNotificationCenterAddObserver(
|
||||
center,
|
||||
observerPtr,
|
||||
callback,
|
||||
"com.kovtash.portalcam.streamStopped" as CFString,
|
||||
nil,
|
||||
.deliverImmediately
|
||||
)
|
||||
}
|
||||
|
||||
private func handleDarwinNotification(_ name: String) {
|
||||
log.info("Received Darwin notification: \(name, privacy: .public)")
|
||||
if name == "com.kovtash.portalcam.streamStarted" {
|
||||
onConsumerStreamStarted?()
|
||||
} else if name == "com.kovtash.portalcam.streamStopped" {
|
||||
onConsumerStreamStopped?()
|
||||
}
|
||||
}
|
||||
|
||||
private func registerSystemDeviceListener() {
|
||||
var addr = CMIOObjectPropertyAddress(
|
||||
mSelector: CMIOObjectPropertySelector(kCMIOHardwarePropertyDevices),
|
||||
mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
|
||||
mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
|
||||
)
|
||||
|
||||
let block: CMIOObjectPropertyListenerBlock = { [weak self] _, _ in
|
||||
guard let self = self else { return }
|
||||
self.ioQueue.async {
|
||||
self.findAndSetupDevice()
|
||||
}
|
||||
}
|
||||
self.systemListenerBlock = block
|
||||
CMIOObjectAddPropertyListenerBlock(
|
||||
CMIOObjectID(kCMIOObjectSystemObject),
|
||||
&addr,
|
||||
DispatchQueue.global(qos: .default),
|
||||
block
|
||||
)
|
||||
}
|
||||
|
||||
private func attachDeviceListeners(for dev: CMIODeviceID) {
|
||||
var addr = CMIOObjectPropertyAddress(
|
||||
mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere),
|
||||
mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
|
||||
mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
|
||||
)
|
||||
|
||||
let block: CMIOObjectPropertyListenerBlock = { [weak self] _, _ in
|
||||
guard let self = self else { return }
|
||||
let running = self.isDeviceRunningSomewhere(dev)
|
||||
self.log.info("CMIODevicePropertyDeviceIsRunningSomewhere changed: \(running)")
|
||||
DispatchQueue.main.async {
|
||||
if running {
|
||||
self.onConsumerStreamStarted?()
|
||||
} else {
|
||||
self.onConsumerStreamStopped?()
|
||||
}
|
||||
}
|
||||
}
|
||||
self.deviceListenerBlock = block
|
||||
CMIOObjectAddPropertyListenerBlock(
|
||||
dev,
|
||||
&addr,
|
||||
DispatchQueue.global(qos: .default),
|
||||
block
|
||||
)
|
||||
}
|
||||
|
||||
private func isDeviceRunningSomewhere(_ dev: CMIODeviceID) -> Bool {
|
||||
var addr = CMIOObjectPropertyAddress(
|
||||
mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere),
|
||||
mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
|
||||
mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
|
||||
)
|
||||
var isRunning: UInt32 = 0
|
||||
let size = UInt32(MemoryLayout<UInt32>.size)
|
||||
var used: UInt32 = 0
|
||||
let status = CMIOObjectGetPropertyData(dev, &addr, 0, nil, size, &used, &isRunning)
|
||||
return status == noErr && isRunning != 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import PortalKit
|
||||
|
||||
final class NativePortalAudio: NSObject, URLSessionDataDelegate {
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionDataTask?
|
||||
private var data = Data()
|
||||
private let engine = AVAudioEngine()
|
||||
private let node = AVAudioPlayerNode()
|
||||
private let compressed: AVAudioFormat
|
||||
private let pcm: AVAudioFormat
|
||||
private let converter: AVAudioConverter
|
||||
private let audioQueue = DispatchQueue(label: "com.kovtash.portalcam.audio.playback", qos: .userInteractive)
|
||||
private let sessionQueue: OperationQueue = {
|
||||
let q = OperationQueue()
|
||||
q.name = "com.kovtash.portalcam.audio.session"
|
||||
q.maxConcurrentOperationCount = 1
|
||||
q.qualityOfService = .userInitiated
|
||||
return q
|
||||
}()
|
||||
private var attached = false
|
||||
private let sync = NSLock()
|
||||
private var shouldRun = false
|
||||
private var host: String?
|
||||
private var token: String?
|
||||
private var retryWork: DispatchWorkItem?
|
||||
|
||||
var onStatus: ((String) -> Void)?
|
||||
var onUnauthorized: (() -> Void)?
|
||||
|
||||
override init() {
|
||||
var asbd = AudioStreamBasicDescription(
|
||||
mSampleRate: 48000,
|
||||
mFormatID: kAudioFormatMPEG4AAC,
|
||||
mFormatFlags: 0,
|
||||
mBytesPerPacket: 0,
|
||||
mFramesPerPacket: 1024,
|
||||
mBytesPerFrame: 0,
|
||||
mChannelsPerFrame: 1,
|
||||
mBitsPerChannel: 0,
|
||||
mReserved: 0
|
||||
)
|
||||
compressed = AVAudioFormat(streamDescription: &asbd)!
|
||||
pcm = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000, channels: 1, interleaved: true)!
|
||||
converter = AVAudioConverter(from: compressed, to: pcm)!
|
||||
super.init()
|
||||
}
|
||||
|
||||
func start(host: String, token: String? = PortalAuth.token) {
|
||||
sync.lock()
|
||||
// Already running (or reconnecting) for this endpoint — keep the live socket.
|
||||
if shouldRun, self.host == host, self.token == token {
|
||||
sync.unlock()
|
||||
return
|
||||
}
|
||||
shouldRun = true
|
||||
self.host = host
|
||||
self.token = token
|
||||
sync.unlock()
|
||||
connectNow()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
sync.lock()
|
||||
shouldRun = false
|
||||
sync.unlock()
|
||||
cancelRetry()
|
||||
// Tear down on the session queue so it can't race with didReceive data.
|
||||
if sessionQueue == OperationQueue.current {
|
||||
tearDownConnection(stopEngine: true)
|
||||
} else {
|
||||
sessionQueue.addOperation { [weak self] in
|
||||
self?.tearDownConnection(stopEngine: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connectNow() {
|
||||
cancelRetry()
|
||||
sessionQueue.addOperation { [weak self] in
|
||||
guard let self else { return }
|
||||
self.tearDownConnection(stopEngine: false)
|
||||
|
||||
self.sync.lock()
|
||||
let host = self.host
|
||||
let token = self.token
|
||||
let running = self.shouldRun
|
||||
self.sync.unlock()
|
||||
guard running, let host else { return }
|
||||
|
||||
if !self.attached {
|
||||
self.engine.attach(self.node)
|
||||
self.engine.connect(self.node, to: self.engine.mainMixerNode, format: self.pcm)
|
||||
self.attached = true
|
||||
}
|
||||
do {
|
||||
if !self.engine.isRunning {
|
||||
try self.engine.start()
|
||||
}
|
||||
if !self.node.isPlaying {
|
||||
self.node.play()
|
||||
}
|
||||
} catch {
|
||||
self.onStatus?("Audio engine failed: \(error.localizedDescription)")
|
||||
self.scheduleRetry(reason: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
self.data.removeAll(keepingCapacity: true)
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.timeoutIntervalForRequest = .greatestFiniteMagnitude
|
||||
cfg.timeoutIntervalForResource = 7 * 24 * 60 * 60
|
||||
cfg.waitsForConnectivity = true
|
||||
let session = URLSession(configuration: cfg, delegate: self, delegateQueue: self.sessionQueue)
|
||||
self.session = session
|
||||
guard let u = URL(string: "https://\(host)/audio.aac") else { return }
|
||||
var req = URLRequest(url: u)
|
||||
if let token {
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
let task = session.dataTask(with: req)
|
||||
self.task = task
|
||||
task.resume()
|
||||
self.onStatus?("Audio connecting…")
|
||||
}
|
||||
}
|
||||
|
||||
private func tearDownConnection(stopEngine: Bool) {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
session?.invalidateAndCancel()
|
||||
session = nil
|
||||
data.removeAll(keepingCapacity: true)
|
||||
if stopEngine {
|
||||
node.stop()
|
||||
if engine.isRunning {
|
||||
engine.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelRetry() {
|
||||
retryWork?.cancel()
|
||||
retryWork = nil
|
||||
}
|
||||
|
||||
private func scheduleRetry(reason: String) {
|
||||
sync.lock()
|
||||
let running = shouldRun
|
||||
sync.unlock()
|
||||
guard running else { return }
|
||||
cancelRetry()
|
||||
onStatus?("Audio reconnecting in 1s…")
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.connectNow()
|
||||
}
|
||||
retryWork = work
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.0, execute: work)
|
||||
}
|
||||
|
||||
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) {
|
||||
if session !== self.session {
|
||||
completionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
if let http = response as? HTTPURLResponse {
|
||||
if http.statusCode == 401 || http.statusCode == 403 {
|
||||
completionHandler(.cancel)
|
||||
sync.lock()
|
||||
shouldRun = false
|
||||
sync.unlock()
|
||||
cancelRetry()
|
||||
onUnauthorized?()
|
||||
return
|
||||
}
|
||||
if http.statusCode >= 400 {
|
||||
completionHandler(.cancel)
|
||||
scheduleRetry(reason: "HTTP \(http.statusCode)")
|
||||
return
|
||||
}
|
||||
}
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive chunk: Data) {
|
||||
guard session === self.session else { return }
|
||||
data.append(chunk)
|
||||
consume()
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard session === self.session else { return }
|
||||
if let error {
|
||||
let ns = error as NSError
|
||||
if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled {
|
||||
return
|
||||
}
|
||||
scheduleRetry(reason: error.localizedDescription)
|
||||
} else {
|
||||
scheduleRetry(reason: "audio closed")
|
||||
}
|
||||
}
|
||||
|
||||
private func consume() {
|
||||
// ADTS sync + frame length are parsed defensively; max frame_length is 13 bits (8191).
|
||||
while data.count >= 7 {
|
||||
let b0 = data[data.startIndex]
|
||||
let b1 = data[data.startIndex + 1]
|
||||
guard b0 == 0xff, (b1 & 0xf0) == 0xf0 else {
|
||||
data.removeFirst()
|
||||
continue
|
||||
}
|
||||
let b3 = Int(data[data.startIndex + 3])
|
||||
let b4 = Int(data[data.startIndex + 4])
|
||||
let b5 = Int(data[data.startIndex + 5])
|
||||
let len = ((b3 & 0x03) << 11) | (b4 << 3) | ((b5 & 0xe0) >> 5)
|
||||
guard len >= 7, len <= 8191 else {
|
||||
data.removeFirst()
|
||||
continue
|
||||
}
|
||||
guard data.count >= len else { return }
|
||||
let packet = data.subdata(in: data.startIndex.advanced(by: 7)..<data.startIndex.advanced(by: len))
|
||||
data.removeSubrange(data.startIndex..<data.startIndex.advanced(by: len))
|
||||
decode(packet)
|
||||
}
|
||||
// Cap buffer growth if sync is lost for a long stretch.
|
||||
if data.count > 256 * 1024 {
|
||||
data.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func decode(_ bytes: Data) {
|
||||
guard !bytes.isEmpty, bytes.count <= 2048 else { return }
|
||||
let b = AVAudioCompressedBuffer(format: compressed, packetCapacity: 1, maximumPacketSize: 2048)
|
||||
bytes.withUnsafeBytes { raw in
|
||||
guard let base = raw.baseAddress else { return }
|
||||
memcpy(b.data, base, bytes.count)
|
||||
}
|
||||
b.byteLength = UInt32(bytes.count)
|
||||
b.packetCount = 1
|
||||
guard let out = AVAudioPCMBuffer(pcmFormat: pcm, frameCapacity: 1024) else { return }
|
||||
var used = false
|
||||
var convertError: NSError?
|
||||
converter.convert(to: out, error: &convertError) { _, status in
|
||||
if used {
|
||||
status.pointee = .endOfStream
|
||||
return nil
|
||||
}
|
||||
used = true
|
||||
status.pointee = .haveData
|
||||
return b
|
||||
}
|
||||
if convertError != nil { return }
|
||||
if out.frameLength > 0 {
|
||||
audioQueue.async {
|
||||
self.node.scheduleBuffer(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import Foundation
|
||||
import VideoToolbox
|
||||
import CoreImage
|
||||
import IOSurface
|
||||
import os.log
|
||||
import PortalKit
|
||||
|
||||
final class NativePortalStream: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate {
|
||||
private let log = Logger(subsystem: "com.kovtash.portalcam", category: "stream")
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionDataTask?
|
||||
private var pending = Data()
|
||||
private let decoder = H264Decoder()
|
||||
private let sync = NSLock()
|
||||
private var shouldRun = false
|
||||
private var host: String?
|
||||
private var token: String?
|
||||
private var retryWork: DispatchWorkItem?
|
||||
private var lastPinMismatch = false
|
||||
private let sessionQueue: OperationQueue = {
|
||||
let q = OperationQueue()
|
||||
q.name = "com.kovtash.portalcam.video.session"
|
||||
q.maxConcurrentOperationCount = 1
|
||||
q.qualityOfService = .userInitiated
|
||||
return q
|
||||
}()
|
||||
|
||||
var onFrame: ((CVPixelBuffer) -> Void)?
|
||||
var onStatus: ((String) -> Void)?
|
||||
var onUnauthorized: (() -> Void)?
|
||||
var onConnected: (() -> Void)?
|
||||
/// Pin mismatch: stream stopped; app stays up.
|
||||
var onPinMismatch: (() -> Void)?
|
||||
|
||||
func start(host: String, token: String? = PortalAuth.token) {
|
||||
sync.lock()
|
||||
// Already running (or reconnecting) for this endpoint — keep the live socket.
|
||||
if shouldRun, self.host == host, self.token == token {
|
||||
sync.unlock()
|
||||
return
|
||||
}
|
||||
shouldRun = true
|
||||
self.host = host
|
||||
self.token = token
|
||||
sync.unlock()
|
||||
connectNow()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
sync.lock()
|
||||
shouldRun = false
|
||||
sync.unlock()
|
||||
cancelRetry()
|
||||
if sessionQueue == OperationQueue.current {
|
||||
tearDownConnection(resetDecoder: true)
|
||||
} else {
|
||||
sessionQueue.addOperation { [weak self] in
|
||||
self?.tearDownConnection(resetDecoder: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connectNow() {
|
||||
cancelRetry()
|
||||
sessionQueue.addOperation { [weak self] in
|
||||
guard let self else { return }
|
||||
self.tearDownConnection(resetDecoder: false)
|
||||
|
||||
self.sync.lock()
|
||||
let host = self.host
|
||||
let token = self.token
|
||||
let running = self.shouldRun
|
||||
self.sync.unlock()
|
||||
guard running, let host else { return }
|
||||
|
||||
self.decoder.onFrame = { [weak self] b in self?.onFrame?(b) }
|
||||
self.pending.removeAll(keepingCapacity: true)
|
||||
self.lastPinMismatch = false
|
||||
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.timeoutIntervalForRequest = .greatestFiniteMagnitude
|
||||
cfg.timeoutIntervalForResource = 7 * 24 * 60 * 60
|
||||
cfg.waitsForConnectivity = true
|
||||
let session = URLSession(configuration: cfg, delegate: self, delegateQueue: self.sessionQueue)
|
||||
self.session = session
|
||||
guard let u = URL(string: "https://\(host)/video.h264") else {
|
||||
self.onStatus?("Invalid Portal address")
|
||||
return
|
||||
}
|
||||
self.onStatus?("Connecting…")
|
||||
self.log.info("connecting to \(u.absoluteString, privacy: .public)")
|
||||
var req = URLRequest(url: u)
|
||||
if let token {
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
let task = session.dataTask(with: req)
|
||||
self.task = task
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func tearDownConnection(resetDecoder: Bool) {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
session?.invalidateAndCancel()
|
||||
session = nil
|
||||
pending.removeAll(keepingCapacity: true)
|
||||
if resetDecoder {
|
||||
decoder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelRetry() {
|
||||
retryWork?.cancel()
|
||||
retryWork = nil
|
||||
}
|
||||
|
||||
private func scheduleRetry(reason: String) {
|
||||
sync.lock()
|
||||
let running = shouldRun
|
||||
sync.unlock()
|
||||
guard running else { return }
|
||||
|
||||
cancelRetry()
|
||||
onStatus?("Reconnecting in 1s… (\(reason))")
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.connectNow()
|
||||
}
|
||||
retryWork = work
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.0, execute: work)
|
||||
}
|
||||
|
||||
private func evaluateChallenge(
|
||||
_ challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
let pinned = PortalAuth.pinnedCertSha256
|
||||
PortalTlsPinning.evaluate(
|
||||
challenge: challenge,
|
||||
pinnedFingerprint: pinned,
|
||||
completionHandler: { [weak self] disposition, credential in
|
||||
if disposition == .cancelAuthenticationChallenge,
|
||||
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust,
|
||||
let cert = PortalTlsPinning.extractLeafCert(from: trust),
|
||||
let pinned {
|
||||
let (_, got) = PortalTlsPinning.computeCertSha256(cert: cert)
|
||||
if pinned.caseInsensitiveCompare(got) != .orderedSame {
|
||||
self?.lastPinMismatch = true
|
||||
}
|
||||
}
|
||||
completionHandler(disposition, credential)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
evaluateChallenge(challenge, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
evaluateChallenge(challenge, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
||||
guard session === self.session else {
|
||||
completionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
if let http = response as? HTTPURLResponse {
|
||||
log.info("response status \(http.statusCode, privacy: .public)")
|
||||
if http.statusCode == 401 || http.statusCode == 403 {
|
||||
completionHandler(.cancel)
|
||||
sync.lock()
|
||||
shouldRun = false
|
||||
sync.unlock()
|
||||
cancelRetry()
|
||||
onUnauthorized?()
|
||||
onStatus?("Not authorized")
|
||||
return
|
||||
}
|
||||
if http.statusCode >= 200 && http.statusCode < 300 {
|
||||
onConnected?()
|
||||
} else if http.statusCode >= 400 {
|
||||
completionHandler(.cancel)
|
||||
scheduleRetry(reason: "HTTP \(http.statusCode)")
|
||||
return
|
||||
}
|
||||
}
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
guard session === self.session else { return }
|
||||
pending.append(data)
|
||||
consume()
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard session === self.session else { return }
|
||||
if lastPinMismatch {
|
||||
lastPinMismatch = false
|
||||
sync.lock()
|
||||
shouldRun = false
|
||||
sync.unlock()
|
||||
cancelRetry()
|
||||
onStatus?("Certificate mismatch — connection rejected. Unpair and pair again if this is your Portal.")
|
||||
onPinMismatch?()
|
||||
return
|
||||
}
|
||||
if let error {
|
||||
let ns = error as NSError
|
||||
if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled {
|
||||
return
|
||||
}
|
||||
log.error("stream ended: \(error.localizedDescription, privacy: .public)")
|
||||
scheduleRetry(reason: error.localizedDescription)
|
||||
} else {
|
||||
scheduleRetry(reason: "stream closed")
|
||||
}
|
||||
}
|
||||
|
||||
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 nalStart = first.0 + first.1
|
||||
guard nalStart <= sc, sc <= pending.count else { break }
|
||||
let nal = pending[nalStart..<sc]
|
||||
decoder.decode(nal: Data(nal))
|
||||
pending.removeSubrange(0..<sc)
|
||||
}
|
||||
if pending.count > 2 * 1024 * 1024 {
|
||||
pending = Data(pending.suffix(256 * 1024))
|
||||
}
|
||||
}
|
||||
|
||||
private func startCode(in d: Data, from: Int) -> (Int, Int)? {
|
||||
let count = d.count
|
||||
if count < 4 || from >= count { return nil }
|
||||
return d.withUnsafeBytes { raw -> (Int, Int)? in
|
||||
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return nil }
|
||||
var i = from
|
||||
let end = count - 2
|
||||
while i < end {
|
||||
if base[i] == 0 && base[i + 1] == 0 && base[i + 2] == 1 {
|
||||
return (i, 3)
|
||||
}
|
||||
if i + 3 < count && base[i] == 0 && base[i + 1] == 0 && base[i + 2] == 0 && base[i + 3] == 1 {
|
||||
return (i, 4)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class H264Decoder {
|
||||
private let log = Logger(subsystem: "com.kovtash.portalcam", category: "decoder")
|
||||
private var sps = Data(), pps = Data(), session: VTDecompressionSession?, format: CMVideoFormatDescription?
|
||||
var onFrame: ((CVPixelBuffer) -> Void)?
|
||||
|
||||
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 {
|
||||
sps = nal
|
||||
rebuild()
|
||||
return
|
||||
}
|
||||
if type == 8 {
|
||||
pps = nal
|
||||
rebuild()
|
||||
return
|
||||
}
|
||||
guard (type == 1 || type == 5), let format, let session 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
|
||||
guard let base = raw.baseAddress else { return }
|
||||
_ = CMBlockBufferCreateWithMemoryBlock(
|
||||
allocator: kCFAllocatorDefault,
|
||||
memoryBlock: UnsafeMutableRawPointer(mutating: base),
|
||||
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 {
|
||||
_ = VTDecompressionSessionDecodeFrame(
|
||||
session,
|
||||
sampleBuffer: sample,
|
||||
flags: [],
|
||||
frameRefcon: Unmanaged.passUnretained(self).toOpaque(),
|
||||
infoFlagsOut: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
guard let sBase = sr.bindMemory(to: UInt8.self).baseAddress,
|
||||
let pBase = pr.bindMemory(to: UInt8.self).baseAddress else {
|
||||
return OSStatus(-1)
|
||||
}
|
||||
var ps = [sBase, pBase]
|
||||
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 {
|
||||
log.error("format creation failed \(rc, privacy: .public)")
|
||||
return
|
||||
}
|
||||
format = f
|
||||
let callback: VTDecompressionOutputCallback = { refCon, _, status, _, image, _, _ in
|
||||
if status == noErr, let image, let refCon {
|
||||
Unmanaged<H264Decoder>.fromOpaque(refCon).takeUnretainedValue().onFrame?(image)
|
||||
}
|
||||
}
|
||||
var cb = VTDecompressionOutputCallbackRecord(
|
||||
decompressionOutputCallback: callback,
|
||||
decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
var attrs: CFDictionary = [
|
||||
kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [:]
|
||||
] as CFDictionary
|
||||
var ds: VTDecompressionSession?
|
||||
_ = VTDecompressionSessionCreate(
|
||||
allocator: kCFAllocatorDefault,
|
||||
formatDescription: f,
|
||||
decoderSpecification: nil,
|
||||
imageBufferAttributes: attrs,
|
||||
outputCallback: &cb,
|
||||
decompressionSessionOut: &ds
|
||||
)
|
||||
session = ds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//
|
||||
// PortalBrowser.swift
|
||||
// PortalCam
|
||||
//
|
||||
// DNS-SD / Bonjour browser for PortalCam services on the LAN.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import Combine
|
||||
import PortalKit
|
||||
|
||||
struct DiscoveredPortal: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let host: String
|
||||
let port: Int
|
||||
|
||||
var endpoint: String { "\(host):\(port)" }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PortalBrowser: ObservableObject {
|
||||
@Published private(set) var portals: [DiscoveredPortal] = []
|
||||
@Published private(set) var isBrowsing = false
|
||||
@Published private(set) var statusMessage = "Looking for Portal TVs…"
|
||||
|
||||
private var browser: NWBrowser?
|
||||
private var resolveConnections: [String: NWConnection] = [:]
|
||||
|
||||
func start() {
|
||||
guard browser == nil else { return }
|
||||
let descriptor = NWBrowser.Descriptor.bonjour(type: PortalEndpoints.bonjourType, domain: nil)
|
||||
let params = NWParameters()
|
||||
params.includePeerToPeer = true
|
||||
let b = NWBrowser(for: descriptor, using: params)
|
||||
browser = b
|
||||
isBrowsing = true
|
||||
statusMessage = "Looking for Portal TVs…"
|
||||
|
||||
b.stateUpdateHandler = { [weak self] newState in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
switch newState {
|
||||
case .ready:
|
||||
self.statusMessage = self.portals.isEmpty ? "Looking for Portal TVs…" : "Select a Portal"
|
||||
case .failed(let err):
|
||||
self.statusMessage = "Browse failed: \(err.localizedDescription)"
|
||||
self.isBrowsing = false
|
||||
case .cancelled:
|
||||
self.isBrowsing = false
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleBrowseResults(results)
|
||||
}
|
||||
}
|
||||
|
||||
b.start(queue: .main)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
for (_, conn) in resolveConnections { conn.cancel() }
|
||||
resolveConnections.removeAll()
|
||||
isBrowsing = false
|
||||
}
|
||||
|
||||
private func handleBrowseResults(_ results: Set<NWBrowser.Result>) {
|
||||
let activeIds = Set(results.map { resultId($0) })
|
||||
for id in resolveConnections.keys where !activeIds.contains(id) {
|
||||
resolveConnections[id]?.cancel()
|
||||
resolveConnections.removeValue(forKey: id)
|
||||
}
|
||||
portals.removeAll { !activeIds.contains($0.id) }
|
||||
|
||||
for result in results {
|
||||
let id = resultId(result)
|
||||
if portals.contains(where: { $0.id == id }) || resolveConnections[id] != nil {
|
||||
continue
|
||||
}
|
||||
resolve(result, id: id)
|
||||
}
|
||||
|
||||
if portals.isEmpty && isBrowsing {
|
||||
statusMessage = "Looking for Portal TVs…"
|
||||
}
|
||||
}
|
||||
|
||||
private func resultId(_ result: NWBrowser.Result) -> String {
|
||||
if case .service(let name, let type, let domain, _) = result.endpoint {
|
||||
return "\(name).\(type).\(domain)"
|
||||
}
|
||||
return String(describing: result.endpoint)
|
||||
}
|
||||
|
||||
private func resolve(_ result: NWBrowser.Result, id: String, preferIPv4: Bool = true) {
|
||||
resolveConnections[id]?.cancel()
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
if preferIPv4, let ip = params.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options {
|
||||
ip.version = .v4
|
||||
}
|
||||
|
||||
let conn = NWConnection(to: result.endpoint, using: params)
|
||||
resolveConnections[id] = conn
|
||||
conn.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
// Ignore stale callbacks after a preferIPv4 → any retry replaced this connection.
|
||||
guard self.resolveConnections[id] === conn else { return }
|
||||
|
||||
switch state {
|
||||
case .ready:
|
||||
guard let portal = self.portal(from: conn, id: id, browseResult: result) else {
|
||||
conn.cancel()
|
||||
self.resolveConnections.removeValue(forKey: id)
|
||||
if preferIPv4 {
|
||||
self.resolve(result, id: id, preferIPv4: false)
|
||||
}
|
||||
return
|
||||
}
|
||||
if preferIPv4, Self.isIPv6Literal(portal.host) {
|
||||
// Dual-stack path still handed us v6 — try unrestricted resolve only if needed.
|
||||
conn.cancel()
|
||||
self.resolveConnections.removeValue(forKey: id)
|
||||
self.resolve(result, id: id, preferIPv4: false)
|
||||
return
|
||||
}
|
||||
if let idx = self.portals.firstIndex(where: { $0.id == portal.id }) {
|
||||
// Prefer keeping an existing IPv4 over replacing with IPv6.
|
||||
if Self.isIPv6Literal(portal.host), !Self.isIPv6Literal(self.portals[idx].host) {
|
||||
conn.cancel()
|
||||
self.resolveConnections.removeValue(forKey: id)
|
||||
return
|
||||
}
|
||||
self.portals[idx] = portal
|
||||
} else {
|
||||
self.portals.append(portal)
|
||||
}
|
||||
self.portals.sort {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
self.statusMessage = "Select a Portal"
|
||||
conn.cancel()
|
||||
self.resolveConnections.removeValue(forKey: id)
|
||||
case .failed:
|
||||
conn.cancel()
|
||||
self.resolveConnections.removeValue(forKey: id)
|
||||
if preferIPv4 {
|
||||
self.resolve(result, id: id, preferIPv4: false)
|
||||
}
|
||||
case .cancelled:
|
||||
if self.resolveConnections[id] === conn {
|
||||
self.resolveConnections.removeValue(forKey: id)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.start(queue: .main)
|
||||
}
|
||||
|
||||
private func portal(from connection: NWConnection, id: String, browseResult: NWBrowser.Result) -> DiscoveredPortal? {
|
||||
let name: String
|
||||
if case .service(let n, _, _, _) = browseResult.endpoint {
|
||||
name = n
|
||||
} else {
|
||||
name = "PortalCam"
|
||||
}
|
||||
|
||||
guard let endpoint = connection.currentPath?.remoteEndpoint else {
|
||||
return serviceFallback(id: id, name: name, result: browseResult)
|
||||
}
|
||||
|
||||
switch endpoint {
|
||||
case .hostPort(let host, let port):
|
||||
guard let hostString = Self.hostString(from: host) else {
|
||||
return serviceFallback(id: id, name: name, result: browseResult)
|
||||
}
|
||||
let resolvedPort = Int(port.rawValue)
|
||||
return DiscoveredPortal(
|
||||
id: id,
|
||||
name: name,
|
||||
host: hostString,
|
||||
port: resolvedPort > 0 ? resolvedPort : PortalEndpoints.port
|
||||
)
|
||||
default:
|
||||
return serviceFallback(id: id, name: name, result: browseResult)
|
||||
}
|
||||
}
|
||||
|
||||
/// Network.framework includes interface scopes (`10.0.0.10%en0`) which break URL hosts.
|
||||
private static func hostString(from host: NWEndpoint.Host) -> String? {
|
||||
switch host {
|
||||
case .name(let n, _):
|
||||
return stripInterfaceScope(n)
|
||||
case .ipv4(let v4):
|
||||
let bytes = v4.rawValue
|
||||
guard bytes.count == 4 else { return stripInterfaceScope(v4.debugDescription) }
|
||||
return "\(bytes[0]).\(bytes[1]).\(bytes[2]).\(bytes[3])"
|
||||
case .ipv6(let v6):
|
||||
return stripInterfaceScope(String(describing: v6))
|
||||
@unknown default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func isIPv6Literal(_ host: String) -> Bool {
|
||||
host.contains(":")
|
||||
}
|
||||
|
||||
private static func stripInterfaceScope(_ value: String) -> String {
|
||||
guard let pct = value.firstIndex(of: "%") else { return value }
|
||||
return String(value[..<pct])
|
||||
}
|
||||
|
||||
private func serviceFallback(id: String, name: String, result: NWBrowser.Result) -> DiscoveredPortal? {
|
||||
guard case .service(_, _, let domain, _) = result.endpoint else {
|
||||
return DiscoveredPortal(id: id, name: name, host: name, port: PortalEndpoints.port)
|
||||
}
|
||||
let encoded = name.replacingOccurrences(of: " ", with: "-")
|
||||
var host = encoded
|
||||
let d = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
if !d.isEmpty {
|
||||
host = "\(encoded).\(d)"
|
||||
} else {
|
||||
host = "\(encoded).local"
|
||||
}
|
||||
return DiscoveredPortal(id: id, name: name, host: host, port: PortalEndpoints.port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?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.developer.system-extension.install</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(TeamIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// PortalCamApp.swift
|
||||
// PortalCam
|
||||
//
|
||||
// Menubar-only companion for Portal TV (no Dock icon).
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import SystemExtensions
|
||||
|
||||
@main
|
||||
struct PortalCamApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@StateObject private var model = PortalReceiverModel()
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
ContentView()
|
||||
.environmentObject(model)
|
||||
.workspaceDetachedMenuBarWindow()
|
||||
.onAppear { model.panelDidAppear() }
|
||||
.onDisappear { model.panelDidDisappear() }
|
||||
} label: {
|
||||
Image(systemName: model.menuBarSymbolName)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
ExtensionInstaller.shared.activate()
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
final class ExtensionInstaller: NSObject, OSSystemExtensionRequestDelegate {
|
||||
static let shared = ExtensionInstaller()
|
||||
func activate() {
|
||||
let extId = "com.kovtash.portalcam.camera-extension"
|
||||
NSLog("PortalCam: preparing to activate \(extId)")
|
||||
let fm = FileManager.default
|
||||
let sysExtsURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/SystemExtensions", isDirectory: true)
|
||||
if let contents = try? fm.contentsOfDirectory(at: sysExtsURL, includingPropertiesForKeys: nil) {
|
||||
let names = contents.map { $0.lastPathComponent }.joined(separator: ", ")
|
||||
NSLog("PortalCam: embedded system extensions: \(names)")
|
||||
for url in contents {
|
||||
let infoURL = url.appendingPathComponent("Contents/Info.plist")
|
||||
if let info = NSDictionary(contentsOf: infoURL), let bid = info["CFBundleIdentifier"] as? String {
|
||||
NSLog("PortalCam: embedded sys ext id: \(bid)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NSLog("PortalCam: no embedded system extensions directory at \(sysExtsURL.path)")
|
||||
}
|
||||
NSLog("PortalCam: submitting camera extension activation")
|
||||
let r = OSSystemExtensionRequest.activationRequest(forExtensionWithIdentifier: extId, queue: .main)
|
||||
r.delegate = self
|
||||
OSSystemExtensionManager.shared.submitRequest(r)
|
||||
}
|
||||
func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
|
||||
NSLog("PortalCam: camera extension needs user approval")
|
||||
}
|
||||
func request(_ request: OSSystemExtensionRequest, actionForReplacingExtension existing: OSSystemExtensionProperties, withExtension replacement: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
|
||||
.replace
|
||||
}
|
||||
func request(_ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result) {
|
||||
NSLog("PortalCam: camera extension activation finished (\(result.rawValue))")
|
||||
ExtensionSinkWriter.shared.resetAndReconnect()
|
||||
}
|
||||
func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
|
||||
NSLog("PortalCam: camera extension activation failed: \(error)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//
|
||||
// PortalMediaSession.swift
|
||||
// PortalCam
|
||||
//
|
||||
// Reference-counted shared Portal media connection. UI preview and virtual-cam
|
||||
// consumers acquire/release interest; HTTPS streams open once on 0→1 and close
|
||||
// on 1→0. Decoded frames fan out to per-consumer handlers without reconnecting.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreVideo
|
||||
import PortalKit
|
||||
|
||||
enum PortalMediaConsumer: Hashable, Sendable {
|
||||
case uiPreview
|
||||
case virtualCamera
|
||||
}
|
||||
|
||||
/// Process-wide media session shared by the menubar UI and the CMIO virtual camera.
|
||||
final class PortalMediaSession: @unchecked Sendable {
|
||||
static let shared = PortalMediaSession()
|
||||
|
||||
private let video = NativePortalStream()
|
||||
private let lock = NSLock()
|
||||
|
||||
private var consumers = Set<PortalMediaConsumer>()
|
||||
private var frameHandlers: [PortalMediaConsumer: (CVPixelBuffer) -> Void] = [:]
|
||||
private var activeHost: String?
|
||||
private var activeToken: String?
|
||||
|
||||
var onStatus: ((String) -> Void)?
|
||||
var onUnauthorized: (() -> Void)?
|
||||
var onConnected: (() -> Void)?
|
||||
var onDisconnected: (() -> Void)?
|
||||
|
||||
var activeConsumers: Set<PortalMediaConsumer> {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return consumers
|
||||
}
|
||||
|
||||
var hasConsumers: Bool {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return !consumers.isEmpty
|
||||
}
|
||||
|
||||
private init() {
|
||||
video.onFrame = { [weak self] buffer in
|
||||
guard let self else { return }
|
||||
let handlers: [(CVPixelBuffer) -> Void] = {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.consumers.compactMap { self.frameHandlers[$0] }
|
||||
}()
|
||||
for handler in handlers {
|
||||
handler(buffer)
|
||||
}
|
||||
}
|
||||
video.onStatus = { [weak self] s in self?.onStatus?(s) }
|
||||
video.onUnauthorized = { [weak self] in self?.onUnauthorized?() }
|
||||
video.onConnected = { [weak self] in self?.onConnected?() }
|
||||
video.onPinMismatch = { [weak self] in
|
||||
// Stream already stopped; surface through status path only — never exit the app.
|
||||
self?.onStatus?(
|
||||
"Certificate mismatch — connection rejected. Unpair and pair again if this is your Portal."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register (or clear) the frame sink for a consumer. Safe to call before acquire.
|
||||
func setFrameHandler(_ consumer: PortalMediaConsumer, _ handler: ((CVPixelBuffer) -> Void)?) {
|
||||
lock.lock()
|
||||
if let handler {
|
||||
frameHandlers[consumer] = handler
|
||||
} else {
|
||||
frameHandlers.removeValue(forKey: consumer)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Express interest in the shared streams. Opens HTTPS only when the first consumer joins.
|
||||
func acquire(_ consumer: PortalMediaConsumer, host: String, token: String?) {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
lock.lock()
|
||||
let wasEmpty = consumers.isEmpty
|
||||
let hostChanged = activeHost != nil && activeHost != trimmed
|
||||
let tokenChanged = activeToken != token && activeHost != nil
|
||||
consumers.insert(consumer)
|
||||
activeHost = trimmed
|
||||
activeToken = token
|
||||
let shouldStart = wasEmpty || hostChanged || tokenChanged
|
||||
lock.unlock()
|
||||
|
||||
guard shouldStart else { return }
|
||||
// Video only for now — audio AAC stream unused by UI / virtual cam.
|
||||
video.start(host: trimmed, token: token)
|
||||
}
|
||||
|
||||
/// Drop interest. Closes HTTPS only when the last consumer leaves.
|
||||
func release(_ consumer: PortalMediaConsumer) {
|
||||
lock.lock()
|
||||
consumers.remove(consumer)
|
||||
let nowEmpty = consumers.isEmpty
|
||||
if nowEmpty {
|
||||
activeHost = nil
|
||||
activeToken = nil
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
guard nowEmpty else { return }
|
||||
video.stop()
|
||||
onDisconnected?()
|
||||
}
|
||||
|
||||
/// Tear down for unpair / auth failure regardless of consumer count.
|
||||
func releaseAll() {
|
||||
lock.lock()
|
||||
consumers.removeAll()
|
||||
activeHost = nil
|
||||
activeToken = nil
|
||||
lock.unlock()
|
||||
video.stop()
|
||||
onDisconnected?()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// WorkspaceDetachedWindow.swift
|
||||
// PortalCam
|
||||
//
|
||||
// Detaches the MenuBarExtra popup from a single Space so it stays available
|
||||
// when switching desktops (same idea as system utility panels).
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
enum WorkspaceDetachedWindow {
|
||||
static let behaviors: NSWindow.CollectionBehavior = [
|
||||
.canJoinAllSpaces,
|
||||
.fullScreenAuxiliary,
|
||||
.stationary,
|
||||
]
|
||||
|
||||
static func apply(to window: NSWindow?) {
|
||||
guard let window else { return }
|
||||
var behavior = window.collectionBehavior
|
||||
behavior.insert(behaviors)
|
||||
behavior.remove(.moveToActiveSpace)
|
||||
window.collectionBehavior = behavior
|
||||
window.isMovableByWindowBackground = false
|
||||
}
|
||||
|
||||
static func applyToVisiblePanels() {
|
||||
for window in NSApp.windows where window.isVisible {
|
||||
// MenuBarExtra window-style panels are typically NSPanel / utility.
|
||||
if window is NSPanel || window.level == .floating || window.level == .statusBar {
|
||||
apply(to: window)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the hosting NSWindow for a SwiftUI view and runs a callback.
|
||||
private struct WindowAccessor: NSViewRepresentable {
|
||||
var onResolve: (NSWindow?) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
onResolve(view.window)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
DispatchQueue.main.async {
|
||||
onResolve(nsView.window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Apply Spaces-detached collection behavior to the MenuBarExtra host window.
|
||||
func workspaceDetachedMenuBarWindow() -> some View {
|
||||
background(
|
||||
WindowAccessor { window in
|
||||
WorkspaceDetachedWindow.apply(to: window)
|
||||
}
|
||||
)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { note in
|
||||
WorkspaceDetachedWindow.apply(to: note.object as? NSWindow)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
WorkspaceDetachedWindow.applyToVisiblePanels()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWorkspace.activeSpaceDidChangeNotification)) { _ in
|
||||
WorkspaceDetachedWindow.applyToVisiblePanels()
|
||||
for window in NSApp.windows where window.isVisible {
|
||||
if window is NSPanel || window.level == .floating || window.level == .statusBar {
|
||||
window.orderFrontRegardless()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user