This commit is contained in:
2026-09-13 12:15:36 -07:00
commit e473d00f4f
104 changed files with 16080 additions and 0 deletions
@@ -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
}
}
+11
View File
@@ -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))
}
}
+14
View File
@@ -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 */;
}
@@ -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>
+804
View File
@@ -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
}
}
+240
View File
@@ -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 01 and close
// on 10. 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()
}
}
}
}
}