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()
}
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "PortalKit",
platforms: [
.macOS(.v12)
],
products: [
.library(
name: "PortalKit",
targets: ["PortalKit"]
),
.executable(
name: "portalkit-cli",
targets: ["portalkit-cli"]
),
],
dependencies: [],
targets: [
.target(
name: "PortalKit",
dependencies: [],
path: "Sources/PortalKit"
),
.executableTarget(
name: "portalkit-cli",
dependencies: ["PortalKit"],
path: "Sources/portalkit-cli"
),
.testTarget(
name: "PortalKitTests",
dependencies: ["PortalKit"],
path: "Tests/PortalKitTests"
),
]
)
@@ -0,0 +1,118 @@
//
// CredentialStorage.swift
// PortalKit
//
// Abstraction for persisting pairing tokens and pinned TLS fingerprints.
//
import Foundation
public protocol CredentialStorage: Sendable {
func getAuthToken() -> String?
func getPinnedCertSha256() -> String?
func save(token: String, certSha256: String)
func clear()
}
/// Thread-safe in-memory credential storage for unit testing and ephemeral sessions.
public final class InMemoryCredentialStorage: CredentialStorage, @unchecked Sendable {
private let lock = NSLock()
private var token: String?
private var certSha256: String?
public init(token: String? = nil, certSha256: String? = nil) {
self.token = token
self.certSha256 = certSha256?.lowercased()
}
public func getAuthToken() -> String? {
lock.lock()
defer { lock.unlock() }
return token
}
public func getPinnedCertSha256() -> String? {
lock.lock()
defer { lock.unlock() }
return certSha256
}
public func save(token: String, certSha256: String) {
lock.lock()
defer { lock.unlock() }
self.token = token
self.certSha256 = certSha256.lowercased()
}
public func clear() {
lock.lock()
defer { lock.unlock() }
self.token = nil
self.certSha256 = nil
}
}
/// JSON-file-backed credential storage (default for CLI: ~/.portalkit/credentials.json).
public final class FileCredentialStorage: CredentialStorage, @unchecked Sendable {
private let fileURL: URL
private let lock = NSLock()
private struct CredentialsRecord: Codable {
var token: String?
var certSha256: String?
}
public init(fileURL: URL? = nil) {
if let url = fileURL {
self.fileURL = url
} else {
let homeDir = FileManager.default.homeDirectoryForCurrentUser
let portalKitDir = homeDir.appendingPathComponent(".portalkit", isDirectory: true)
try? FileManager.default.createDirectory(at: portalKitDir, withIntermediateDirectories: true)
self.fileURL = portalKitDir.appendingPathComponent("credentials.json")
}
}
private func loadRecord() -> CredentialsRecord {
guard let data = try? Data(contentsOf: fileURL),
let record = try? JSONDecoder().decode(CredentialsRecord.self, from: data) else {
return CredentialsRecord(token: nil, certSha256: nil)
}
return record
}
private func persist(record: CredentialsRecord) {
let parentDir = fileURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true)
if let data = try? JSONEncoder().encode(record) {
try? data.write(to: fileURL, options: .atomic)
// Ensure permissions are 0600 (owner read/write only)
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
}
}
public func getAuthToken() -> String? {
lock.lock()
defer { lock.unlock() }
return loadRecord().token
}
public func getPinnedCertSha256() -> String? {
lock.lock()
defer { lock.unlock() }
return loadRecord().certSha256
}
public func save(token: String, certSha256: String) {
lock.lock()
defer { lock.unlock() }
let record = CredentialsRecord(token: token, certSha256: certSha256.lowercased())
persist(record: record)
}
public func clear() {
lock.lock()
defer { lock.unlock() }
try? FileManager.default.removeItem(at: fileURL)
}
}
@@ -0,0 +1,189 @@
//
// KeychainCredentialStorage.swift
// PortalKit
//
// macOS Keychain credential storage with Data Protection Keychain and fallback.
//
import Foundation
import Security
import os.log
public final class KeychainCredentialStorage: CredentialStorage, @unchecked Sendable {
private let log = Logger(subsystem: "com.kovtash.portalkit", category: "keychain")
public let service: String
public let accessGroup: String?
public let suiteName: String?
public let tokenKey: String
public let certKey: String
public init(
service: String = "com.kovtash.portalcam.auth",
accessGroup: String? = "ENT9X9U544.com.kovtash.portalcam",
suiteName: String? = "ENT9X9U544.com.kovtash.portalcam",
tokenKey: String = "portalAuthToken",
certKey: String = "portalPinnedCertSha256"
) {
self.service = service
self.accessGroup = accessGroup
self.suiteName = suiteName
self.tokenKey = tokenKey
self.certKey = certKey
}
public func getAuthToken() -> String? {
readItem(account: tokenKey)
}
public func getPinnedCertSha256() -> String? {
readItem(account: certKey)
}
public func save(token: String, certSha256: String) {
saveItem(account: tokenKey, value: token)
saveItem(account: certKey, value: certSha256.lowercased())
}
public func clear() {
deleteItem(account: tokenKey)
deleteItem(account: certKey)
}
// MARK: - Private Keychain Helpers
private func saveItem(account: String, value: String) {
let data = Data(value.utf8)
// Delete from legacy file-based keychain if present
let legacyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
_ = SecItemDelete(legacyQuery as CFDictionary)
// Delete existing item in Data Protection Keychain
var dpDeleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true
]
if let ag = accessGroup {
dpDeleteQuery[kSecAttrAccessGroup as String] = ag
}
_ = SecItemDelete(dpDeleteQuery as CFDictionary)
// Try adding with accessGroup if specified
var addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecUseDataProtectionKeychain as String: true
]
if let ag = accessGroup {
addQuery[kSecAttrAccessGroup as String] = ag
}
var status = SecItemAdd(addQuery as CFDictionary, nil)
// If access group fails due to missing entitlements (e.g. running from CLI or test runner),
// retry without access group.
if status == errSecMissingEntitlement && accessGroup != nil {
addQuery.removeValue(forKey: kSecAttrAccessGroup as String)
status = SecItemAdd(addQuery as CFDictionary, nil)
}
if status != errSecSuccess {
log.error("PortalKit: Keychain save failed for \(account, privacy: .public): \(status)")
}
// Shared App Group fallback if suiteName provided
if let suite = suiteName {
UserDefaults(suiteName: suite)?.set(value, forKey: account)
}
}
private func readItem(account: String) -> String? {
// 1. Try Data Protection Keychain with accessGroup
var dpQuery: [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
]
if let ag = accessGroup {
dpQuery[kSecAttrAccessGroup as String] = ag
}
var item: CFTypeRef?
var status = SecItemCopyMatching(dpQuery as CFDictionary, &item)
// If failed with missing entitlement, retry without access group
if status == errSecMissingEntitlement && accessGroup != nil {
dpQuery.removeValue(forKey: kSecAttrAccessGroup as String)
status = SecItemCopyMatching(dpQuery as CFDictionary, &item)
}
if status == errSecSuccess, let data = item as? Data,
let value = String(data: data, encoding: .utf8) {
return value
}
// 2. Fallback to 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) {
return value
}
// 3. Fallback to App Group UserDefaults
if let suite = suiteName, let value = UserDefaults(suiteName: suite)?.string(forKey: account) {
return value
}
return nil
}
private func deleteItem(account: String) {
let legacyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
SecItemDelete(legacyQuery as CFDictionary)
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true
]
if let ag = accessGroup {
query[kSecAttrAccessGroup as String] = ag
}
SecItemDelete(query as CFDictionary)
if accessGroup != nil {
query.removeValue(forKey: kSecAttrAccessGroup as String)
SecItemDelete(query as CFDictionary)
}
if let suite = suiteName {
UserDefaults(suiteName: suite)?.removeObject(forKey: account)
}
}
}
@@ -0,0 +1,58 @@
//
// PortalAuth.swift
// PortalKit
//
// Static convenience facade around CredentialStorage matching the PortalCam interface.
//
import Foundation
public enum PortalAuth {
public static let suite = "ENT9X9U544.com.kovtash.portalcam"
public static let accessGroup = "ENT9X9U544.com.kovtash.portalcam"
public static let service = "com.kovtash.portalcam.auth"
public static let tokenKey = "portalAuthToken"
public static let certKey = "portalPinnedCertSha256"
private static let lock = NSLock()
private static var _defaultStorage: CredentialStorage = KeychainCredentialStorage(
service: service,
accessGroup: accessGroup,
suiteName: suite,
tokenKey: tokenKey,
certKey: certKey
)
public static var defaultStorage: CredentialStorage {
get {
lock.lock()
defer { lock.unlock() }
return _defaultStorage
}
set {
lock.lock()
defer { lock.unlock() }
_defaultStorage = newValue
}
}
/// Reads the auth token from the default credential storage
public static var token: String? {
defaultStorage.getAuthToken()
}
/// Reads the pinned certificate SHA-256 fingerprint from the default credential storage
public static var pinnedCertSha256: String? {
defaultStorage.getPinnedCertSha256()
}
/// Saves both auth token and pinned certificate fingerprint
public static func save(token: String, certSha256: String) {
defaultStorage.save(token: token, certSha256: certSha256)
}
/// Removes both auth token and pinned certificate
public static func clear() {
defaultStorage.clear()
}
}
@@ -0,0 +1,126 @@
//
// Addition.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension BigUInt {
//MARK: Addition
/// Add `word` to this integer in place.
/// `word` is shifted `shift` words to the left before being added.
///
/// - Complexity: O(max(count, shift))
internal mutating func addWord(_ word: Word, shiftedBy shift: Int = 0) {
precondition(shift >= 0)
var carry = word
var i = shift
while carry > 0 {
let (d, c) = self[i].addingReportingOverflow(carry)
self[i] = d
carry = (c ? 1 : 0)
i += 1
}
}
/// Add the digit `d` to this integer and return the result.
/// `d` is shifted `shift` words to the left before being added.
///
/// - Complexity: O(max(count, shift))
internal func addingWord(_ word: Word, shiftedBy shift: Int = 0) -> BigUInt {
var r = self
r.addWord(word, shiftedBy: shift)
return r
}
/// Add `b` to this integer in place.
/// `b` is shifted `shift` words to the left before being added.
///
/// - Complexity: O(max(count, b.count + shift))
internal mutating func add(_ b: BigUInt, shiftedBy shift: Int = 0) {
precondition(shift >= 0)
var carry = false
var bi = 0
let bc = b.count
while bi < bc || carry {
let ai = shift + bi
let (d, c) = self[ai].addingReportingOverflow(b[bi])
if carry {
let (d2, c2) = d.addingReportingOverflow(1)
self[ai] = d2
carry = c || c2
}
else {
self[ai] = d
carry = c
}
bi += 1
}
}
/// Add `b` to this integer and return the result.
/// `b` is shifted `shift` words to the left before being added.
///
/// - Complexity: O(max(count, b.count + shift))
internal func adding(_ b: BigUInt, shiftedBy shift: Int = 0) -> BigUInt {
var r = self
r.add(b, shiftedBy: shift)
return r
}
/// Increment this integer by one. If `shift` is non-zero, it selects
/// the word that is to be incremented.
///
/// - Complexity: O(count + shift)
internal mutating func increment(shiftedBy shift: Int = 0) {
self.addWord(1, shiftedBy: shift)
}
/// Add `a` and `b` together and return the result.
///
/// - Complexity: O(max(a.count, b.count))
public static func +(a: BigUInt, b: BigUInt) -> BigUInt {
return a.adding(b)
}
/// Add `a` and `b` together, and store the sum in `a`.
///
/// - Complexity: O(max(a.count, b.count))
public static func +=(a: inout BigUInt, b: BigUInt) {
a.add(b, shiftedBy: 0)
}
}
extension BigInt {
/// Add `a` to `b` and return the result.
public static func +(a: BigInt, b: BigInt) -> BigInt {
switch (a.sign, b.sign) {
case (.plus, .plus):
return BigInt(sign: .plus, magnitude: a.magnitude + b.magnitude)
case (.minus, .minus):
return BigInt(sign: .minus, magnitude: a.magnitude + b.magnitude)
case (.plus, .minus):
if a.magnitude >= b.magnitude {
return BigInt(sign: .plus, magnitude: a.magnitude - b.magnitude)
}
else {
return BigInt(sign: .minus, magnitude: b.magnitude - a.magnitude)
}
case (.minus, .plus):
if b.magnitude >= a.magnitude {
return BigInt(sign: .plus, magnitude: b.magnitude - a.magnitude)
}
else {
return BigInt(sign: .minus, magnitude: a.magnitude - b.magnitude)
}
}
}
/// Add `b` to `a` in place.
public static func +=(a: inout BigInt, b: BigInt) {
a = a + b
}
}
@@ -0,0 +1,74 @@
//
// BigInt.swift
// BigInt
//
// Created by Károly Lőrentey on 2015-12-27.
// Copyright © 2016-2017 Károly Lőrentey.
//
//MARK: BigInt
/// An arbitary precision signed integer type, also known as a "big integer".
///
/// Operations on big integers never overflow, but they might take a long time to execute.
/// The amount of memory (and address space) available is the only constraint to the magnitude of these numbers.
///
/// This particular big integer type uses base-2^64 digits to represent integers.
///
/// `BigInt` is essentially a tiny wrapper that extends `BigUInt` with a sign bit and provides signed integer
/// operations. Both the underlying absolute value and the negative/positive flag are available as read-write
/// properties.
///
/// Not all algorithms of `BigUInt` are available for `BigInt` values; for example, there is no square root or
/// primality test for signed integers. When you need to call one of these, just extract the absolute value:
///
/// ```Swift
/// BigInt(255).magnitude.isPrime() // Returns false
/// ```
///
public struct BigInt: SignedInteger, Sendable {
public enum Sign: Sendable {
case plus
case minus
}
public typealias Magnitude = BigUInt
/// The type representing a digit in `BigInt`'s underlying number system.
public typealias Word = BigUInt.Word
public static var isSigned: Bool {
return true
}
/// The absolute value of this integer.
public var magnitude: BigUInt
/// True iff the value of this integer is negative.
public var sign: Sign
/// Initializes a new big integer with the provided absolute number and sign flag.
public init(sign: Sign, magnitude: BigUInt) {
self.sign = (magnitude.isZero ? .plus : sign)
self.magnitude = magnitude
}
/// Return true iff this integer is zero.
///
/// - Complexity: O(1)
public var isZero: Bool {
return magnitude.isZero
}
/// Returns `-1` if this value is negative and `1` if its positive; otherwise, `0`.
///
/// - Returns: The sign of this number, expressed as an integer of the same type.
public func signum() -> BigInt {
switch sign {
case .plus:
return isZero ? 0 : 1
case .minus:
return -1
}
}
}
@@ -0,0 +1,386 @@
//
// BigUInt.swift
// BigInt
//
// Created by Károly Lőrentey on 2015-12-26.
// Copyright © 2016-2017 Károly Lőrentey.
//
/// An arbitary precision unsigned integer type, also known as a "big integer".
///
/// Operations on big integers never overflow, but they may take a long time to execute.
/// The amount of memory (and address space) available is the only constraint to the magnitude of these numbers.
///
/// This particular big integer type uses base-2^64 digits to represent integers; you can think of it as a wrapper
/// around `Array<UInt64>`. (In fact, `BigUInt` only uses an array if there are more than two digits.)
public struct BigUInt: UnsignedInteger, Sendable {
/// The type representing a digit in `BigUInt`'s underlying number system.
public typealias Word = UInt
/// The storage variants of a `BigUInt`.
enum Kind {
/// Value consists of the two specified words (low and high). Either or both words may be zero.
case inline(Word, Word)
/// Words are stored in a slice of the storage array.
case slice(from: Int, to: Int)
/// Words are stored in the storage array.
case array
}
fileprivate(set) var kind: Kind // Internal for testing only
fileprivate(set) var storage: [Word] // Internal for testing only; stored separately to prevent COW copies
/// Initializes a new BigUInt with value 0.
public init() {
self.kind = .inline(0, 0)
self.storage = []
}
internal init(word: Word) {
self.kind = .inline(word, 0)
self.storage = []
}
internal init(low: Word, high: Word) {
self.kind = .inline(low, high)
self.storage = []
}
/// Initializes a new BigUInt with the specified digits. The digits are ordered from least to most significant.
public init(words: [Word]) {
self.kind = .array
self.storage = words
normalize()
}
internal init(words: [Word], from startIndex: Int, to endIndex: Int) {
self.kind = .slice(from: startIndex, to: endIndex)
self.storage = words
normalize()
}
}
extension BigUInt {
public static var isSigned: Bool {
return false
}
/// Return true iff this integer is zero.
///
/// - Complexity: O(1)
public var isZero: Bool {
switch kind {
case .inline(0, 0): return true
case .array: return storage.isEmpty
default:
return false
}
}
/// Returns `1` if this value is, positive; otherwise, `0`.
///
/// - Returns: The sign of this number, expressed as an integer of the same type.
public func signum() -> BigUInt {
return isZero ? 0 : 1
}
}
extension BigUInt {
mutating func ensureArray() {
switch kind {
case let .inline(w0, w1):
kind = .array
storage = w1 != 0 ? [w0, w1]
: w0 != 0 ? [w0]
: []
case let .slice(from: start, to: end):
kind = .array
storage = Array(storage[start ..< end])
case .array:
break
}
}
var capacity: Int {
guard case .array = kind else { return 0 }
return storage.capacity
}
mutating func reserveCapacity(_ minimumCapacity: Int) {
switch kind {
case let .inline(w0, w1):
kind = .array
storage.reserveCapacity(minimumCapacity)
if w1 != 0 {
storage.append(w0)
storage.append(w1)
}
else if w0 != 0 {
storage.append(w0)
}
case let .slice(from: start, to: end):
kind = .array
var words: [Word] = []
words.reserveCapacity(Swift.max(end - start, minimumCapacity))
words.append(contentsOf: storage[start ..< end])
storage = words
case .array:
storage.reserveCapacity(minimumCapacity)
}
}
/// Gets rid of leading zero digits in the digit array and converts slices into inline digits when possible.
internal mutating func normalize() {
switch kind {
case .slice(from: let start, to: var end):
assert(start >= 0 && end <= storage.count && start <= end)
while start < end, storage[end - 1] == 0 {
end -= 1
}
switch end - start {
case 0:
kind = .inline(0, 0)
storage = []
case 1:
kind = .inline(storage[start], 0)
storage = []
case 2:
kind = .inline(storage[start], storage[start + 1])
storage = []
case storage.count:
assert(start == 0)
kind = .array
default:
kind = .slice(from: start, to: end)
}
case .array where storage.last == 0:
while storage.last == 0 {
storage.removeLast()
}
default:
break
}
}
/// Set this integer to 0 without releasing allocated storage capacity (if any).
mutating func clear() {
self.load(0)
}
/// Set this integer to `value` by copying its digits without releasing allocated storage capacity (if any).
mutating func load(_ value: BigUInt) {
switch kind {
case .inline, .slice:
self = value
case .array:
self.storage.removeAll(keepingCapacity: true)
self.storage.append(contentsOf: value.words)
}
}
}
extension BigUInt {
//MARK: Collection-like members
/// The number of digits in this integer, excluding leading zero digits.
var count: Int {
switch kind {
case let .inline(w0, w1):
return w1 != 0 ? 2
: w0 != 0 ? 1
: 0
case let .slice(from: start, to: end):
return end - start
case .array:
return storage.count
}
}
/// Get or set a digit at a given index.
///
/// - Note: Unlike a normal collection, it is OK for the index to be greater than or equal to `endIndex`.
/// The subscripting getter returns zero for indexes beyond the most significant digit.
/// Setting these extended digits automatically appends new elements to the underlying digit array.
/// - Requires: index >= 0
/// - Complexity: The getter is O(1). The setter is O(1) if the conditions below are true; otherwise it's O(count).
/// - The integer's storage is not shared with another integer
/// - The integer wasn't created as a slice of another integer
/// - `index < count`
subscript(_ index: Int) -> Word {
get {
precondition(index >= 0)
switch (kind, index) {
case (.inline(let w0, _), 0): return w0
case (.inline(_, let w1), 1): return w1
case (.slice(from: let start, to: let end), _) where index < end - start:
return storage[start + index]
case (.array, _) where index < storage.count:
return storage[index]
default:
return 0
}
}
set(word) {
precondition(index >= 0)
switch (kind, index) {
case let (.inline(_, w1), 0):
kind = .inline(word, w1)
case let (.inline(w0, _), 1):
kind = .inline(w0, word)
case let (.slice(from: start, to: end), _) where index < end - start:
replace(at: index, with: word)
case (.array, _) where index < storage.count:
replace(at: index, with: word)
default:
extend(at: index, with: word)
}
}
}
private mutating func replace(at index: Int, with word: Word) {
ensureArray()
precondition(index < storage.count)
storage[index] = word
if word == 0, index == storage.count - 1 {
normalize()
}
}
private mutating func extend(at index: Int, with word: Word) {
guard word != 0 else { return }
reserveCapacity(index + 1)
precondition(index >= storage.count)
storage.append(contentsOf: repeatElement(0, count: index - storage.count))
storage.append(word)
}
/// Returns an integer built from the digits of this integer in the given range.
internal func extract(_ bounds: Range<Int>) -> BigUInt {
switch kind {
case let .inline(w0, w1):
let bounds = bounds.clamped(to: 0 ..< 2)
if bounds == 0 ..< 2 {
return BigUInt(low: w0, high: w1)
}
else if bounds == 0 ..< 1 {
return BigUInt(word: w0)
}
else if bounds == 1 ..< 2 {
return BigUInt(word: w1)
}
else {
return BigUInt()
}
case let .slice(from: start, to: end):
let s = Swift.min(end, start + Swift.max(bounds.lowerBound, 0))
let e = Swift.max(s, (bounds.upperBound > end - start ? end : start + bounds.upperBound))
return BigUInt(words: storage, from: s, to: e)
case .array:
let b = bounds.clamped(to: storage.startIndex ..< storage.endIndex)
return BigUInt(words: storage, from: b.lowerBound, to: b.upperBound)
}
}
internal func extract<Bounds: RangeExpression>(_ bounds: Bounds) -> BigUInt where Bounds.Bound == Int {
return self.extract(bounds.relative(to: 0 ..< Int.max))
}
}
extension BigUInt {
internal mutating func shiftRight(byWords amount: Int) {
assert(amount >= 0)
guard amount > 0 else { return }
switch kind {
case let .inline(_, w1) where amount == 1:
kind = .inline(w1, 0)
case .inline(_, _):
kind = .inline(0, 0)
case let .slice(from: start, to: end):
let s = start + amount
if s >= end {
kind = .inline(0, 0)
}
else {
kind = .slice(from: s, to: end)
normalize()
}
case .array:
if amount >= storage.count {
storage.removeAll(keepingCapacity: true)
}
else {
storage.removeFirst(amount)
}
}
}
internal mutating func shiftLeft(byWords amount: Int) {
assert(amount >= 0)
guard amount > 0 else { return }
guard !isZero else { return }
switch kind {
case let .inline(w0, 0) where amount == 1:
kind = .inline(0, w0)
case let .inline(w0, w1):
let c = (w1 == 0 ? 1 : 2)
storage.reserveCapacity(amount + c)
storage.append(contentsOf: repeatElement(0, count: amount))
storage.append(w0)
if w1 != 0 {
storage.append(w1)
}
kind = .array
case let .slice(from: start, to: end):
var words: [Word] = []
words.reserveCapacity(amount + count)
words.append(contentsOf: repeatElement(0, count: amount))
words.append(contentsOf: storage[start ..< end])
storage = words
kind = .array
case .array:
storage.insert(contentsOf: repeatElement(0, count: amount), at: 0)
}
}
}
extension BigUInt {
//MARK: Low and High
/// Split this integer into a high-order and a low-order part.
///
/// - Requires: count > 1
/// - Returns: `(low, high)` such that
/// - `self == low.add(high, shiftedBy: middleIndex)`
/// - `high.width <= floor(width / 2)`
/// - `low.width <= ceil(width / 2)`
/// - Complexity: Typically O(1), but O(count) in the worst case, because high-order zero digits need to be removed after the split.
internal var split: (high: BigUInt, low: BigUInt) {
precondition(count > 1)
let mid = middleIndex
return (self.extract(mid...), self.extract(..<mid))
}
/// Index of the digit at the middle of this integer.
///
/// - Returns: The index of the digit that is least significant in `self.high`.
internal var middleIndex: Int {
return (count + 1) / 2
}
/// The low-order half of this BigUInt.
///
/// - Returns: `self[0 ..< middleIndex]`
/// - Requires: count > 1
internal var low: BigUInt {
return self.extract(0 ..< middleIndex)
}
/// The high-order half of this BigUInt.
///
/// - Returns: `self[middleIndex ..< count]`
/// - Requires: count > 1
internal var high: BigUInt {
return self.extract(middleIndex ..< count)
}
}
@@ -0,0 +1,121 @@
//
// Bitwise Ops.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
//MARK: Bitwise Operations
extension BigUInt {
/// Return the ones' complement of `a`.
///
/// - Complexity: O(a.count)
public static prefix func ~(a: BigUInt) -> BigUInt {
return BigUInt(words: a.words.map { ~$0 })
}
/// Calculate the bitwise OR of `a` and `b`, and store the result in `a`.
///
/// - Complexity: O(max(a.count, b.count))
public static func |= (a: inout BigUInt, b: BigUInt) {
a.reserveCapacity(b.count)
for i in 0 ..< b.count {
a[i] |= b[i]
}
}
/// Calculate the bitwise AND of `a` and `b` and return the result.
///
/// - Complexity: O(max(a.count, b.count))
public static func &= (a: inout BigUInt, b: BigUInt) {
for i in 0 ..< Swift.max(a.count, b.count) {
a[i] &= b[i]
}
}
/// Calculate the bitwise XOR of `a` and `b` and return the result.
///
/// - Complexity: O(max(a.count, b.count))
public static func ^= (a: inout BigUInt, b: BigUInt) {
a.reserveCapacity(b.count)
for i in 0 ..< b.count {
a[i] ^= b[i]
}
}
}
extension BigInt {
public static prefix func ~(x: BigInt) -> BigInt {
switch x.sign {
case .plus:
return BigInt(sign: .minus, magnitude: x.magnitude + 1)
case .minus:
return BigInt(sign: .plus, magnitude: x.magnitude - 1)
}
}
public static func &(lhs: inout BigInt, rhs: BigInt) -> BigInt {
let left = lhs.words
let right = rhs.words
// Note we aren't using left.count/right.count here; we account for the sign bit separately later.
let count = Swift.max(lhs.magnitude.count, rhs.magnitude.count)
var words: [UInt] = []
words.reserveCapacity(count)
for i in 0 ..< count {
words.append(left[i] & right[i])
}
if lhs.sign == .minus && rhs.sign == .minus {
words.twosComplement()
return BigInt(sign: .minus, magnitude: BigUInt(words: words))
}
return BigInt(sign: .plus, magnitude: BigUInt(words: words))
}
public static func |(lhs: inout BigInt, rhs: BigInt) -> BigInt {
let left = lhs.words
let right = rhs.words
// Note we aren't using left.count/right.count here; we account for the sign bit separately later.
let count = Swift.max(lhs.magnitude.count, rhs.magnitude.count)
var words: [UInt] = []
words.reserveCapacity(count)
for i in 0 ..< count {
words.append(left[i] | right[i])
}
if lhs.sign == .minus || rhs.sign == .minus {
words.twosComplement()
return BigInt(sign: .minus, magnitude: BigUInt(words: words))
}
return BigInt(sign: .plus, magnitude: BigUInt(words: words))
}
public static func ^(lhs: inout BigInt, rhs: BigInt) -> BigInt {
let left = lhs.words
let right = rhs.words
// Note we aren't using left.count/right.count here; we account for the sign bit separately later.
let count = Swift.max(lhs.magnitude.count, rhs.magnitude.count)
var words: [UInt] = []
words.reserveCapacity(count)
for i in 0 ..< count {
words.append(left[i] ^ right[i])
}
if (lhs.sign == .minus) != (rhs.sign == .minus) {
words.twosComplement()
return BigInt(sign: .minus, magnitude: BigUInt(words: words))
}
return BigInt(sign: .plus, magnitude: BigUInt(words: words))
}
public static func &=(lhs: inout BigInt, rhs: BigInt) {
lhs = lhs & rhs
}
public static func |=(lhs: inout BigInt, rhs: BigInt) {
lhs = lhs | rhs
}
public static func ^=(lhs: inout BigInt, rhs: BigInt) {
lhs = lhs ^ rhs
}
}
@@ -0,0 +1,169 @@
//
// Codable.swift
// BigInt
//
// Created by Károly Lőrentey on 2017-8-11.
// Copyright © 2016-2017 Károly Lőrentey.
//
// Little-endian to big-endian
struct Units<Unit: FixedWidthInteger, Words: RandomAccessCollection>: RandomAccessCollection
where Words.Element: FixedWidthInteger, Words.Index == Int {
typealias Word = Words.Element
let words: Words
init(of type: Unit.Type, _ words: Words) {
precondition(Word.bitWidth % Unit.bitWidth == 0 || Unit.bitWidth % Word.bitWidth == 0)
self.words = words
}
var count: Int { return (words.count * Word.bitWidth + Unit.bitWidth - 1) / Unit.bitWidth }
var startIndex: Int { return 0 }
var endIndex: Int { return count }
subscript(_ index: Int) -> Unit {
let index = count - 1 - index
if Unit.bitWidth == Word.bitWidth {
return Unit(words[index])
}
else if Unit.bitWidth > Word.bitWidth {
let c = Unit.bitWidth / Word.bitWidth
var unit: Unit = 0
var j = 0
for i in (c * index) ..< Swift.min(c * (index + 1), words.endIndex) {
unit |= Unit(words[i]) << j
j += Word.bitWidth
}
return unit
}
// Unit.bitWidth < Word.bitWidth
let c = Word.bitWidth / Unit.bitWidth
let i = index / c
let j = index % c
return Unit(truncatingIfNeeded: words[i] >> (j * Unit.bitWidth))
}
}
extension Array where Element: FixedWidthInteger {
// Big-endian to little-endian
init<Unit: FixedWidthInteger>(count: Int?, generator: () throws -> Unit?) rethrows {
typealias Word = Element
precondition(Word.bitWidth % Unit.bitWidth == 0 || Unit.bitWidth % Word.bitWidth == 0)
self = []
if Unit.bitWidth == Word.bitWidth {
if let count = count {
self.reserveCapacity(count)
}
while let unit = try generator() {
self.append(Word(unit))
}
}
else if Unit.bitWidth > Word.bitWidth {
let wordsPerUnit = Unit.bitWidth / Word.bitWidth
if let count = count {
self.reserveCapacity(count * wordsPerUnit)
}
while let unit = try generator() {
var shift = Unit.bitWidth - Word.bitWidth
while shift >= 0 {
self.append(Word(truncatingIfNeeded: unit >> shift))
shift -= Word.bitWidth
}
}
}
else {
let unitsPerWord = Word.bitWidth / Unit.bitWidth
if let count = count {
self.reserveCapacity((count + unitsPerWord - 1) / unitsPerWord)
}
var word: Word = 0
var c = 0
while let unit = try generator() {
word <<= Unit.bitWidth
word |= Word(unit)
c += Unit.bitWidth
if c == Word.bitWidth {
self.append(word)
word = 0
c = 0
}
}
if c > 0 {
self.append(word << c)
var shifted: Word = 0
for i in self.indices {
let word = self[i]
self[i] = shifted | (word >> c)
shifted = word << (Word.bitWidth - c)
}
}
}
self.reverse()
}
}
extension BigInt: Codable {
public init(from decoder: Decoder) throws {
if let container = try? decoder.singleValueContainer(), let stringValue = try? container.decode(String.self) {
if stringValue.hasPrefix("0x") || stringValue.hasPrefix("0X") {
guard let bigUInt = BigUInt(stringValue.dropFirst(2), radix: 16) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid hexadecimal BigInt string")
}
self.init(sign: .plus, magnitude: bigUInt)
} else {
guard let bigInt = BigInt(stringValue) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid decimal BigInt string")
}
self = bigInt
}
} else {
var container = try decoder.unkeyedContainer()
// Decode sign
let sign: BigInt.Sign
switch try container.decode(String.self) {
case "+":
sign = .plus
case "-":
sign = .minus
default:
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath,
debugDescription: "Invalid big integer sign"))
}
// Decode magnitude
let words = try [UInt](count: container.count?.advanced(by: -1)) { () -> UInt64? in
guard !container.isAtEnd else { return nil }
return try container.decode(UInt64.self)
}
let magnitude = BigUInt(words: words)
self.init(sign: sign, magnitude: magnitude)
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(sign == .plus ? "+" : "-")
let units = Units(of: UInt64.self, self.magnitude.words)
if units.isEmpty {
try container.encode(0 as UInt64)
}
else {
try container.encode(contentsOf: units)
}
}
}
extension BigUInt: Codable {
public init(from decoder: Decoder) throws {
let value = try BigInt(from: decoder)
guard value.sign == .plus else {
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath,
debugDescription: "BigUInt cannot hold a negative value"))
}
self = value.magnitude
}
public func encode(to encoder: Encoder) throws {
try BigInt(sign: .plus, magnitude: self).encode(to: encoder)
}
}
@@ -0,0 +1,73 @@
//
// Comparable.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
#if canImport(Foundation)
import Foundation
#endif
extension BigUInt: Comparable {
#if !canImport(Foundation)
public enum ComparisonResult: Sendable, Comparable, Hashable {
case orderedDescending
case orderedSame
case orderedAscending
}
#endif
//MARK: Comparison
/// Compare `a` to `b` and return an `NSComparisonResult` indicating their order.
///
/// - Complexity: O(count)
public static func compare(_ a: BigUInt, _ b: BigUInt) -> ComparisonResult {
if a.count != b.count { return a.count > b.count ? .orderedDescending : .orderedAscending }
for i in (0 ..< a.count).reversed() {
let ad = a[i]
let bd = b[i]
if ad != bd { return ad > bd ? .orderedDescending : .orderedAscending }
}
return .orderedSame
}
/// Return true iff `a` is equal to `b`.
///
/// - Complexity: O(count)
public static func ==(a: BigUInt, b: BigUInt) -> Bool {
return BigUInt.compare(a, b) == .orderedSame
}
/// Return true iff `a` is less than `b`.
///
/// - Complexity: O(count)
public static func <(a: BigUInt, b: BigUInt) -> Bool {
return BigUInt.compare(a, b) == .orderedAscending
}
}
extension BigInt: Comparable {
/// Return true iff `a` is equal to `b`.
public static func ==(a: BigInt, b: BigInt) -> Bool {
return a.sign == b.sign && a.magnitude == b.magnitude
}
/// Return true iff `a` is less than `b`.
public static func <(a: BigInt, b: BigInt) -> Bool {
switch (a.sign, b.sign) {
case (.plus, .plus):
return a.magnitude < b.magnitude
case (.plus, .minus):
return false
case (.minus, .plus):
return true
case (.minus, .minus):
return a.magnitude > b.magnitude
}
}
}
@@ -0,0 +1,165 @@
//
// Data Conversion.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-04.
// Copyright © 2016-2017 Károly Lőrentey.
//
#if canImport(Foundation)
import Foundation
#endif
extension BigUInt {
//MARK: NSData Conversion
/// Initialize a BigInt from bytes accessed from an UnsafeRawBufferPointer
public init(_ buffer: UnsafeRawBufferPointer) {
// This assumes Word is binary.
precondition(Word.bitWidth % 8 == 0)
self.init()
let length = buffer.count
guard length > 0 else { return }
let bytesPerDigit = Word.bitWidth / 8
var index = length / bytesPerDigit
var c = bytesPerDigit - length % bytesPerDigit
if c == bytesPerDigit {
c = 0
index -= 1
}
var word: Word = 0
for byte in buffer {
word <<= 8
word += Word(byte)
c += 1
if c == bytesPerDigit {
self[index] = word
index -= 1
c = 0
word = 0
}
}
assert(c == 0 && word == 0 && index == -1)
}
/// Return a `UnsafeRawBufferPointer` buffer that contains the base-256 representation of this integer, in network (big-endian) byte order.
public func serializeToBuffer() -> UnsafeRawBufferPointer {
// This assumes Digit is binary.
precondition(Word.bitWidth % 8 == 0)
let byteCount = (self.bitWidth + 7) / 8
let buffer = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: byteCount)
guard byteCount > 0 else { return UnsafeRawBufferPointer(start: buffer.baseAddress, count: 0) }
var i = byteCount - 1
for var word in self.words {
for _ in 0 ..< Word.bitWidth / 8 {
buffer[i] = UInt8(word & 0xFF)
word >>= 8
if i == 0 {
assert(word == 0)
break
}
i -= 1
}
}
let zeroOut = UnsafeMutableBufferPointer<UInt8>(start: buffer.baseAddress, count: i)
zeroOut.initialize(repeating: 0)
return UnsafeRawBufferPointer(start: buffer.baseAddress, count: byteCount)
}
#if canImport(Foundation)
/// Initializes an integer from the bits stored inside a piece of `Data`.
/// The data is assumed to be in network (big-endian) byte order.
public init(_ data: Data) {
self = data.withUnsafeBytes({ buffer in
BigUInt(buffer)
})
}
/// Return a `Data` value that contains the base-256 representation of this integer, in network (big-endian) byte order.
public func serialize() -> Data {
let buffer = serializeToBuffer()
defer { buffer.deallocate() }
guard
let pointer = buffer.baseAddress.map(UnsafeMutableRawPointer.init(mutating:))
else { return Data() }
return Data(bytes: pointer, count: buffer.count)
}
#endif
}
extension BigInt {
/// Initialize a BigInt from bytes accessed from an UnsafeRawBufferPointer,
/// where the first byte indicates sign (0 for positive, 1 for negative)
public init(_ buffer: UnsafeRawBufferPointer) {
// This assumes Word is binary.
precondition(Word.bitWidth % 8 == 0)
self.init()
let length = buffer.count
// Serialized data for a BigInt should contain at least 2 bytes: one representing
// the sign, and another for the non-zero magnitude. Zero is represented by an
// empty Data struct, and negative zero is not supported.
guard length > 1, let firstByte = buffer.first else { return }
// The first byte gives the sign
// This byte is compared to a bitmask to allow additional functionality to be added
// to this byte in the future.
self.sign = firstByte & 0b1 == 0 ? .plus : .minus
self.magnitude = BigUInt(UnsafeRawBufferPointer(rebasing: buffer.dropFirst(1)))
}
/// Return a `Data` value that contains the base-256 representation of this integer, in network (big-endian) byte order and a prepended byte to indicate the sign (0 for positive, 1 for negative)
public func serializeToBuffer() -> UnsafeRawBufferPointer {
// Create a data object for the magnitude portion of the BigInt
let magnitudeBuffer = self.magnitude.serializeToBuffer()
// Similar to BigUInt, a value of 0 should return an empty buffer
guard magnitudeBuffer.count > 0 else { return magnitudeBuffer }
// Create a new buffer for the signed BigInt value
let newBuffer = UnsafeMutableRawBufferPointer.allocate(byteCount: magnitudeBuffer.count + 1, alignment: 8)
let magnitudeSection = UnsafeMutableRawBufferPointer(rebasing: newBuffer[1...])
magnitudeSection.copyBytes(from: magnitudeBuffer)
magnitudeBuffer.deallocate()
// The first byte should be 0 for a positive value, or 1 for a negative value
// i.e., the sign bit is the LSB
newBuffer[0] = self.sign == .plus ? 0 : 1
return UnsafeRawBufferPointer(start: newBuffer.baseAddress, count: newBuffer.count)
}
#if canImport(Foundation)
/// Initializes an integer from the bits stored inside a piece of `Data`.
/// The data is assumed to be in network (big-endian) byte order with a first
/// byte to represent the sign (0 for positive, 1 for negative)
public init(_ data: Data) {
self = data.withUnsafeBytes({ buffer in
BigInt(buffer)
})
}
/// Return a `Data` value that contains the base-256 representation of this integer, in network (big-endian) byte order and a prepended byte to indicate the sign (0 for positive, 1 for negative)
public func serialize() -> Data {
let buffer = serializeToBuffer()
defer { buffer.deallocate() }
guard
let pointer = buffer.baseAddress.map(UnsafeMutableRawPointer.init(mutating:))
else { return Data() }
return Data(bytes: pointer, count: buffer.count)
}
#endif
}
@@ -0,0 +1,375 @@
//
// Division.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
//MARK: Full-width multiplication and division
// TODO: Return to `where Magnitude == Self` when SR-13491 is resolved
extension FixedWidthInteger {
private var halfShift: Self {
return Self(Self.bitWidth / 2)
}
private var high: Self {
return self &>> halfShift
}
private var low: Self {
let mask: Self = 1 &<< halfShift - 1
return self & mask
}
private var upshifted: Self {
return self &<< halfShift
}
private var split: (high: Self, low: Self) {
return (self.high, self.low)
}
private init(_ value: (high: Self, low: Self)) {
self = value.high.upshifted + value.low
}
/// Divide the double-width integer `dividend` by `self` and return the quotient and remainder.
///
/// - Requires: `dividend.high < self`, so that the result will fit in a single digit.
/// - Complexity: O(1) with 2 divisions, 6 multiplications and ~12 or so additions/subtractions.
internal func fastDividingFullWidth(_ dividend: (high: Self, low: Self.Magnitude)) -> (quotient: Self, remainder: Self) {
// Division is complicated; doing it with single-digit operations is maddeningly complicated.
// This is a Swift adaptation for "divlu2" in Hacker's Delight,
// which is in turn a C adaptation of Knuth's Algorithm D (TAOCP vol 2, 4.3.1).
precondition(dividend.high < self)
// This replaces the implementation in stdlib, which is much slower.
// FIXME: Speed up stdlib. It should use full-width idiv on Intel processors, and
// fall back to a reasonably fast algorithm elsewhere.
// The trick here is that we're actually implementing a 4/2 long division using half-words,
// with the long division loop unrolled into two 3/2 half-word divisions.
// Luckily, 3/2 half-word division can be approximated by a single full-word division operation
// that, when the divisor is normalized, differs from the correct result by at most 2.
/// Find the half-word quotient in `u / vn`, which must be normalized.
/// `u` contains three half-words in the two halves of `u.high` and the lower half of
/// `u.low`. (The weird distribution makes for a slightly better fit with the input.)
/// `vn` contains the normalized divisor, consisting of two half-words.
///
/// - Requires: u.high < vn && u.low.high == 0 && vn.leadingZeroBitCount == 0
func quotient(dividing u: (high: Self, low: Self), by vn: Self) -> Self {
let (vn1, vn0) = vn.split
// Get approximate quotient.
let (q, r) = u.high.quotientAndRemainder(dividingBy: vn1)
let p = q * vn0
// q is often already correct, but sometimes the approximation overshoots by at most 2.
// The code that follows checks for this while being careful to only perform single-digit operations.
if q.high == 0 && p <= r.upshifted + u.low { return q }
let r2 = r + vn1
if r2.high != 0 { return q - 1 }
if (q - 1).high == 0 && p - vn0 <= r2.upshifted + u.low { return q - 1 }
//assert((r + 2 * vn1).high != 0 || p - 2 * vn0 <= (r + 2 * vn1).upshifted + u.low)
return q - 2
}
/// Divide 3 half-digits by 2 half-digits to get a half-digit quotient and a full-digit remainder.
///
/// - Requires: u.high < v && u.low.high == 0 && vn.width = width(Digit)
func quotientAndRemainder(dividing u: (high: Self, low: Self), by v: Self) -> (quotient: Self, remainder: Self) {
let q = quotient(dividing: u, by: v)
// Note that `uh.low` masks off a couple of bits, and `q * v` and the
// subtraction are likely to overflow. Despite this, the end result (remainder) will
// still be correct and it will fit inside a single (full) Digit.
let r = Self(u) &- q &* v
assert(r < v)
return (q, r)
}
// Normalize the dividend and the divisor (self) such that the divisor has no leading zeroes.
let z = Self(self.leadingZeroBitCount)
let w = Self(Self.bitWidth) - z
let vn = self << z
let un32 = (z == 0 ? dividend.high : (dividend.high &<< z) | ((dividend.low as! Self) &>> w)) // No bits are lost
let un10 = dividend.low &<< z
let (un1, un0) = un10.split
// Divide `(un32,un10)` by `vn`, splitting the full 4/2 division into two 3/2 ones.
let (q1, un21) = quotientAndRemainder(dividing: (un32, (un1 as! Self)), by: vn)
let (q0, rn) = quotientAndRemainder(dividing: (un21, (un0 as! Self)), by: vn)
// Undo normalization of the remainder and combine the two halves of the quotient.
let mod = rn >> z
let div = Self((q1, q0))
return (div, mod)
}
/// Return the quotient of the 3/2-word division `x/y` as a single word.
///
/// - Requires: (x.0, x.1) <= y && y.0.high != 0
/// - Returns: The exact value when it fits in a single word, otherwise `Self`.
static func approximateQuotient(dividing x: (Self, Self, Self), by y: (Self, Self)) -> Self {
// Start with q = (x.0, x.1) / y.0, (or Word.max on overflow)
var q: Self
var r: Self
if x.0 == y.0 {
q = Self.max
let (s, o) = x.0.addingReportingOverflow(x.1)
if o { return q }
r = s
}
else {
(q, r) = y.0.fastDividingFullWidth((x.0, (x.1 as! Magnitude)))
}
// Now refine q by considering x.2 and y.1.
// Note that since y is normalized, q * y - x is between 0 and 2.
let (ph, pl) = q.multipliedFullWidth(by: y.1)
if ph < r || (ph == r && pl <= x.2) { return q }
let (r1, ro) = r.addingReportingOverflow(y.0)
if ro { return q - 1 }
let (pl1, so) = pl.subtractingReportingOverflow((y.1 as! Magnitude))
let ph1 = (so ? ph - 1 : ph)
if ph1 < r1 || (ph1 == r1 && pl1 <= x.2) { return q - 1 }
return q - 2
}
}
extension BigUInt {
//MARK: Division
/// Divide this integer by the word `y`, leaving the quotient in its place and returning the remainder.
///
/// - Requires: y > 0
/// - Complexity: O(count)
internal mutating func divide(byWord y: Word) -> Word {
precondition(y > 0)
if y == 1 { return 0 }
var remainder: Word = 0
for i in (0 ..< count).reversed() {
let u = self[i]
(self[i], remainder) = y.fastDividingFullWidth((remainder, u))
}
return remainder
}
/// Divide this integer by the word `y` and return the resulting quotient and remainder.
///
/// - Requires: y > 0
/// - Returns: (quotient, remainder) where quotient = floor(x/y), remainder = x - quotient * y
/// - Complexity: O(x.count)
internal func quotientAndRemainder(dividingByWord y: Word) -> (quotient: BigUInt, remainder: Word) {
var div = self
let mod = div.divide(byWord: y)
return (div, mod)
}
/// Divide `x` by `y`, putting the quotient in `x` and the remainder in `y`.
/// Reusing integers like this reduces the number of allocations during the calculation.
static func divide(_ x: inout BigUInt, by y: inout BigUInt) {
// This is a Swift adaptation of "divmnu" from Hacker's Delight, which is in
// turn a C adaptation of Knuth's Algorithm D (TAOCP vol 2, 4.3.1).
precondition(!y.isZero)
// First, let's take care of the easy cases.
if x < y {
(x, y) = (0, x)
return
}
if y.count == 1 {
// The single-word case reduces to a simpler loop.
y = BigUInt(x.divide(byWord: y[0]))
return
}
// In the hard cases, we will perform the long division algorithm we learned in school.
// It works by successively calculating the single-word quotient of the top y.count + 1
// words of x divided by y, replacing the top of x with the remainder, and repeating
// the process one word lower.
//
// The tricky part is that the algorithm needs to be able to do n+1/n word divisions,
// but we only have a primitive for dividing two words by a single
// word. (Remember that this step is also tricky when we do it on paper!)
//
// The solution is that the long division can be approximated by a single full division
// using just the most significant words. We can then use multiplications and
// subtractions to refine the approximation until we get the correct quotient word.
//
// We could do this by doing a simple 2/1 full division, but Knuth goes one step further,
// and implements a 3/2 division. This results in an exact approximation in the
// vast majority of cases, eliminating an extra subtraction over big integers.
//
// The function `approximateQuotient` above implements Knuth's 3/2 division algorithm.
// It requires that the divisor's most significant word is larger than
// Word.max / 2. This ensures that the approximation has tiny error bounds,
// which is what makes this entire approach viable.
// To satisfy this requirement, we will normalize the division by multiplying
// both the divisor and the dividend by the same (small) factor.
let z = y.leadingZeroBitCount
y <<= z
x <<= z // We'll calculate the remainder in the normalized dividend.
var quotient = BigUInt()
assert(y.leadingZeroBitCount == 0)
// We're ready to start the long division!
let dc = y.count
let d1 = y[dc - 1]
let d0 = y[dc - 2]
var product: BigUInt = 0
for j in (dc ... x.count).reversed() {
// Approximate dividing the top dc+1 words of `remainder` using the topmost 3/2 words.
let r2 = x[j]
let r1 = x[j - 1]
let r0 = x[j - 2]
let q = Word.approximateQuotient(dividing: (r2, r1, r0), by: (d1, d0))
// Multiply the entire divisor with `q` and subtract the result from remainder.
// Normalization ensures the 3/2 quotient will either be exact for the full division, or
// it may overshoot by at most 1, in which case the product will be greater
// than the remainder.
product.load(y)
product.multiply(byWord: q)
if product <= x.extract(j - dc ..< j + 1) {
x.subtract(product, shiftedBy: j - dc)
quotient[j - dc] = q
}
else {
// This case is extremely rare -- it has a probability of 1/2^(Word.bitWidth - 1).
x.add(y, shiftedBy: j - dc)
x.subtract(product, shiftedBy: j - dc)
quotient[j - dc] = q - 1
}
}
// The remainder's normalization needs to be undone, but otherwise we're done.
x >>= z
y = x
x = quotient
}
/// Divide `x` by `y`, putting the remainder in `x`.
mutating func formRemainder(dividingBy y: BigUInt, normalizedBy shift: Int) {
precondition(!y.isZero)
assert(y.leadingZeroBitCount == 0)
if y.count == 1 {
let remainder = self.divide(byWord: y[0] >> shift)
self.load(BigUInt(remainder))
return
}
self <<= shift
if self >= y {
let dc = y.count
let d1 = y[dc - 1]
let d0 = y[dc - 2]
var product: BigUInt = 0
for j in (dc ... self.count).reversed() {
let r2 = self[j]
let r1 = self[j - 1]
let r0 = self[j - 2]
let q = Word.approximateQuotient(dividing: (r2, r1, r0), by: (d1, d0))
product.load(y)
product.multiply(byWord: q)
if product <= self.extract(j - dc ..< j + 1) {
self.subtract(product, shiftedBy: j - dc)
}
else {
self.add(y, shiftedBy: j - dc)
self.subtract(product, shiftedBy: j - dc)
}
}
}
self >>= shift
}
/// Divide this integer by `y` and return the resulting quotient and remainder.
///
/// - Requires: `y > 0`
/// - Returns: `(quotient, remainder)` where `quotient = floor(self/y)`, `remainder = self - quotient * y`
/// - Complexity: O(count^2)
public func quotientAndRemainder(dividingBy y: BigUInt) -> (quotient: BigUInt, remainder: BigUInt) {
var x = self
var y = y
BigUInt.divide(&x, by: &y)
return (x, y)
}
/// Divide `x` by `y` and return the quotient.
///
/// - Note: Use `divided(by:)` if you also need the remainder.
public static func /(x: BigUInt, y: BigUInt) -> BigUInt {
return x.quotientAndRemainder(dividingBy: y).quotient
}
/// Divide `x` by `y` and return the remainder.
///
/// - Note: Use `divided(by:)` if you also need the remainder.
public static func %(x: BigUInt, y: BigUInt) -> BigUInt {
var x = x
let shift = y.leadingZeroBitCount
x.formRemainder(dividingBy: y << shift, normalizedBy: shift)
return x
}
/// Divide `x` by `y` and store the quotient in `x`.
///
/// - Note: Use `divided(by:)` if you also need the remainder.
public static func /=(x: inout BigUInt, y: BigUInt) {
var y = y
BigUInt.divide(&x, by: &y)
}
/// Divide `x` by `y` and store the remainder in `x`.
///
/// - Note: Use `divided(by:)` if you also need the remainder.
public static func %=(x: inout BigUInt, y: BigUInt) {
let shift = y.leadingZeroBitCount
x.formRemainder(dividingBy: y << shift, normalizedBy: shift)
}
}
extension BigInt {
/// Divide this integer by `y` and return the resulting quotient and remainder.
///
/// - Requires: `y > 0`
/// - Returns: `(quotient, remainder)` where `quotient = floor(self/y)`, `remainder = self - quotient * y`
/// - Complexity: O(count^2)
public func quotientAndRemainder(dividingBy y: BigInt) -> (quotient: BigInt, remainder: BigInt) {
var a = self.magnitude
var b = y.magnitude
BigUInt.divide(&a, by: &b)
return (BigInt(sign: self.sign == y.sign ? .plus : .minus, magnitude: a),
BigInt(sign: self.sign, magnitude: b))
}
/// Divide `a` by `b` and return the quotient. Traps if `b` is zero.
public static func /(a: BigInt, b: BigInt) -> BigInt {
return BigInt(sign: a.sign == b.sign ? .plus : .minus, magnitude: a.magnitude / b.magnitude)
}
/// Divide `a` by `b` and return the remainder. The result has the same sign as `a`.
public static func %(a: BigInt, b: BigInt) -> BigInt {
return BigInt(sign: a.sign, magnitude: a.magnitude % b.magnitude)
}
/// Return the result of `a` mod `b`. The result is always a nonnegative integer that is less than the absolute value of `b`.
public func modulus(_ mod: BigInt) -> BigInt {
let remainder = self.magnitude % mod.magnitude
return BigInt(
self.sign == .minus && !remainder.isZero
? mod.magnitude - remainder
: remainder)
}
}
extension BigInt {
/// Divide `a` by `b` storing the quotient in `a`.
public static func /=(a: inout BigInt, b: BigInt) { a = a / b }
/// Divide `a` by `b` storing the remainder in `a`.
public static func %=(a: inout BigInt, b: BigInt) { a = a % b }
}
@@ -0,0 +1,119 @@
//
// Exponentiation.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension BigUInt {
//MARK: Exponentiation
/// Returns this integer raised to the power `exponent`.
///
/// This function calculates the result by [successively squaring the base while halving the exponent][expsqr].
///
/// [expsqr]: https://en.wikipedia.org/wiki/Exponentiation_by_squaring
///
/// - Note: This function can be unreasonably expensive for large exponents, which is why `exponent` is
/// a simple integer value. If you want to calculate big exponents, you'll probably need to use
/// the modulo arithmetic variant.
/// - Returns: 1 if `exponent == 0`, otherwise `self` raised to `exponent`. (This implies that `0.power(0) == 1`.)
/// - SeeAlso: `BigUInt.power(_:, modulus:)`
/// - Complexity: O((exponent * self.count)^log2(3)) or somesuch. The result may require a large amount of memory, too.
public func power(_ exponent: Int) -> BigUInt {
if exponent == 0 { return 1 }
if exponent == 1 { return self }
if exponent < 0 {
precondition(!self.isZero)
return self == 1 ? 1 : 0
}
if self <= 1 { return self }
var result = BigUInt(1)
var b = self
var e = exponent
while e > 0 {
if e & 1 == 1 {
result *= b
}
e >>= 1
b *= b
}
return result
}
/// Returns the remainder of this integer raised to the power `exponent` in modulo arithmetic under `modulus`.
///
/// Uses the [right-to-left binary method][rtlb].
///
/// [rtlb]: https://en.wikipedia.org/wiki/Modular_exponentiation#Right-to-left_binary_method
///
/// - Complexity: O(exponent.count * modulus.count^log2(3)) or somesuch
public func power(_ exponent: BigUInt, modulus: BigUInt) -> BigUInt {
precondition(!modulus.isZero)
if modulus == (1 as BigUInt) { return 0 }
let shift = modulus.leadingZeroBitCount
let normalizedModulus = modulus << shift
var result = BigUInt(1)
var b = self
b.formRemainder(dividingBy: normalizedModulus, normalizedBy: shift)
for var e in exponent.words {
for _ in 0 ..< Word.bitWidth {
if e & 1 == 1 {
result *= b
result.formRemainder(dividingBy: normalizedModulus, normalizedBy: shift)
}
e >>= 1
b *= b
b.formRemainder(dividingBy: normalizedModulus, normalizedBy: shift)
}
}
return result
}
}
extension BigInt {
/// Returns this integer raised to the power `exponent`.
///
/// This function calculates the result by [successively squaring the base while halving the exponent][expsqr].
///
/// [expsqr]: https://en.wikipedia.org/wiki/Exponentiation_by_squaring
///
/// - Note: This function can be unreasonably expensive for large exponents, which is why `exponent` is
/// a simple integer value. If you want to calculate big exponents, you'll probably need to use
/// the modulo arithmetic variant.
/// - Returns: 1 if `exponent == 0`, otherwise `self` raised to `exponent`. (This implies that `0.power(0) == 1`.)
/// - SeeAlso: `BigUInt.power(_:, modulus:)`
/// - Complexity: O((exponent * self.count)^log2(3)) or somesuch. The result may require a large amount of memory, too.
public func power(_ exponent: Int) -> BigInt {
return BigInt(sign: self.sign == .minus && exponent & 1 != 0 ? .minus : .plus,
magnitude: self.magnitude.power(exponent))
}
/// Returns the remainder of this integer raised to the power `exponent` in modulo arithmetic under `modulus`.
///
/// Uses the [right-to-left binary method][rtlb].
///
/// [rtlb]: https://en.wikipedia.org/wiki/Modular_exponentiation#Right-to-left_binary_method
///
/// - Complexity: O(exponent.count * modulus.count^log2(3)) or somesuch
public func power(_ exponent: BigInt, modulus: BigInt) -> BigInt {
precondition(!modulus.isZero)
if modulus.magnitude == 1 { return 0 }
if exponent.isZero { return 1 }
if exponent == 1 { return self.modulus(modulus) }
if exponent < 0 {
precondition(!self.isZero)
guard magnitude == 1 else { return 0 }
guard sign == .minus else { return 1 }
guard exponent.magnitude[0] & 1 != 0 else { return 1 }
return BigInt(modulus.magnitude - 1)
}
let power = self.magnitude.power(exponent.magnitude,
modulus: modulus.magnitude)
if self.sign == .plus || exponent.magnitude[0] & 1 == 0 || power.isZero {
return BigInt(power)
}
return BigInt(modulus.magnitude - power)
}
}
@@ -0,0 +1,181 @@
//
// Floating Point Conversion.swift
// BigInt
//
// Created by Károly Lőrentey on 2017-08-11.
// Copyright © 2016-2017 Károly Lőrentey.
//
#if canImport(Foundation)
import Foundation
#endif
extension BigUInt {
public init?<T: BinaryFloatingPoint>(exactly source: T) {
guard source.isFinite else { return nil }
guard !source.isZero else { self = 0; return }
guard source.sign == .plus else { return nil }
let value = source.rounded(.towardZero)
guard value == source else { return nil }
assert(value.floatingPointClass == .positiveNormal)
assert(value.exponent >= 0)
let significand = value.significandBitPattern
self = (BigUInt(1) << value.exponent) + BigUInt(significand) >> (T.significandBitCount - Int(value.exponent))
}
public init<T: BinaryFloatingPoint>(_ source: T) {
self.init(exactly: source.rounded(.towardZero))!
}
#if canImport(Foundation)
public init?(exactly source: Decimal) {
guard source.exponent >= 0 else { return nil }
self.init(commonDecimal: source)
}
public init?(truncating source: Decimal) {
self.init(commonDecimal: source)
}
private init?(commonDecimal source: Decimal) {
var integer = source
if source.exponent < 0 {
var source = source
NSDecimalRound(&integer, &source, 0, .down)
}
guard !integer.isZero else { self = 0; return }
guard integer.isFinite else { return nil }
guard integer.sign == .plus else { return nil }
assert(integer.floatingPointClass == .positiveNormal)
#if os(Linux) || os(Android) || os(Windows) || os(WASI)
// `Decimal._mantissa` has an internal access level on linux, and it might get
// deprecated in the future, so keeping the string implementation around for now.
let significand = BigUInt("\(integer.significand)")!
#else
let significand = {
var start = BigUInt(0)
for (place, value) in integer.significand.mantissaParts.enumerated() {
guard value > 0 else { continue }
start += (1 << (place * 16)) * BigUInt(value)
}
return start
}()
#endif
let exponent = BigUInt(10).power(integer.exponent)
self = significand * exponent
}
#endif
}
extension BigInt {
public init?<T: BinaryFloatingPoint>(exactly source: T) {
guard let magnitude = BigUInt(exactly: source.magnitude) else { return nil }
let sign = BigInt.Sign(source.sign)
self.init(sign: sign, magnitude: magnitude)
}
public init<T: BinaryFloatingPoint>(_ source: T) {
self.init(exactly: source.rounded(.towardZero))!
}
#if canImport(Foundation)
public init?(exactly source: Decimal) {
guard let magnitude = BigUInt(exactly: source.magnitude) else { return nil }
let sign = BigInt.Sign(source.sign)
self.init(sign: sign, magnitude: magnitude)
}
public init?(truncating source: Decimal) {
guard let magnitude = BigUInt(truncating: source.magnitude) else { return nil }
let sign = BigInt.Sign(source.sign)
self.init(sign: sign, magnitude: magnitude)
}
#endif
}
extension BinaryFloatingPoint where RawExponent: FixedWidthInteger, RawSignificand: FixedWidthInteger {
public init(_ value: BigInt) {
guard !value.isZero else { self = 0; return }
let v = value.magnitude
let bitWidth = v.bitWidth
var exponent = bitWidth - 1
let shift = bitWidth - Self.significandBitCount - 1
var significand = value.magnitude >> (shift - 1)
if significand[0] & 3 == 3 { // Handle rounding
significand >>= 1
significand += 1
if significand.trailingZeroBitCount >= Self.significandBitCount {
exponent += 1
}
}
else {
significand >>= 1
}
let bias = 1 << (Self.exponentBitCount - 1) - 1
guard exponent <= bias else { self = Self.infinity; return }
significand &= 1 << Self.significandBitCount - 1
self = Self.init(sign: value.sign == .plus ? .plus : .minus,
exponentBitPattern: RawExponent(bias + exponent),
significandBitPattern: RawSignificand(significand))
}
public init(_ value: BigUInt) {
self.init(BigInt(sign: .plus, magnitude: value))
}
}
extension BigInt.Sign {
public init(_ sign: FloatingPointSign) {
switch sign {
case .plus:
self = .plus
case .minus:
self = .minus
}
}
}
#if canImport(Foundation)
public extension Decimal {
init(_ value: BigUInt) {
guard
value < BigUInt(exactly: Decimal.greatestFiniteMagnitude)!
else {
self = .greatestFiniteMagnitude
return
}
guard !value.isZero else { self = 0; return }
self.init(string: "\(value)")!
}
init(_ value: BigInt) {
if value >= 0 {
self.init(BigUInt(value))
} else {
self.init(value.magnitude)
self *= -1
}
}
}
#endif
#if canImport(Foundation) && !(os(Linux) || os(Android) || os(Windows) || os(WASI))
private extension Decimal {
var mantissaParts: [UInt16] {
[
_mantissa.0,
_mantissa.1,
_mantissa.2,
_mantissa.3,
_mantissa.4,
_mantissa.5,
_mantissa.6,
_mantissa.7,
]
}
}
#endif
@@ -0,0 +1,80 @@
//
// GCD.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension BigUInt {
//MARK: Greatest Common Divisor
/// Returns the greatest common divisor of `self` and `b`.
///
/// - Complexity: O(count^2) where count = max(self.count, b.count)
public func greatestCommonDivisor(with b: BigUInt) -> BigUInt {
// This is Stein's algorithm: https://en.wikipedia.org/wiki/Binary_GCD_algorithm
if self.isZero { return b }
if b.isZero { return self }
let az = self.trailingZeroBitCount
let bz = b.trailingZeroBitCount
let twos = Swift.min(az, bz)
var (x, y) = (self >> az, b >> bz)
if x < y { swap(&x, &y) }
while !x.isZero {
x >>= x.trailingZeroBitCount
if x < y { swap(&x, &y) }
x -= y
}
return y << twos
}
/// Returns the [multiplicative inverse of this integer in modulo `modulus` arithmetic][inverse],
/// or `nil` if there is no such number.
///
/// [inverse]: https://en.wikipedia.org/wiki/Extended_Euclidean_algorithm#Modular_integers
///
/// - Returns: If `gcd(self, modulus) == 1`, the value returned is an integer `a < modulus` such that `(a * self) % modulus == 1`. If `self` and `modulus` aren't coprime, the return value is `nil`.
/// - Requires: modulus > 1
/// - Complexity: O(count^3)
public func inverse(_ modulus: BigUInt) -> BigUInt? {
precondition(modulus > 1)
var t1 = BigInt(0)
var t2 = BigInt(1)
var r1 = modulus
var r2 = self
while !r2.isZero {
let quotient = r1 / r2
(t1, t2) = (t2, t1 - BigInt(quotient) * t2)
(r1, r2) = (r2, r1 - quotient * r2)
}
if r1 > 1 { return nil }
if t1.sign == .minus { return modulus - t1.magnitude }
return t1.magnitude
}
}
extension BigInt {
/// Returns the greatest common divisor of `a` and `b`.
///
/// - Complexity: O(count^2) where count = max(a.count, b.count)
public func greatestCommonDivisor(with b: BigInt) -> BigInt {
return BigInt(self.magnitude.greatestCommonDivisor(with: b.magnitude))
}
/// Returns the [multiplicative inverse of this integer in modulo `modulus` arithmetic][inverse],
/// or `nil` if there is no such number.
///
/// [inverse]: https://en.wikipedia.org/wiki/Extended_Euclidean_algorithm#Modular_integers
///
/// - Returns: If `gcd(self, modulus) == 1`, the value returned is an integer `a < modulus` such that `(a * self) % modulus == 1`. If `self` and `modulus` aren't coprime, the return value is `nil`.
/// - Requires: modulus.magnitude > 1
/// - Complexity: O(count^3)
public func inverse(_ modulus: BigInt) -> BigInt? {
guard let inv = self.magnitude.inverse(modulus.magnitude) else { return nil }
return BigInt(self.sign == .plus || inv.isZero ? inv : modulus.magnitude - inv)
}
}
@@ -0,0 +1,26 @@
//
// Hashable.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension BigUInt: Hashable {
//MARK: Hashing
/// Append this `BigUInt` to the specified hasher.
public func hash(into hasher: inout Hasher) {
for word in self.words {
hasher.combine(word)
}
}
}
extension BigInt: Hashable {
/// Append this `BigInt` to the specified hasher.
public func hash(into hasher: inout Hasher) {
hasher.combine(sign)
hasher.combine(magnitude)
}
}
@@ -0,0 +1,89 @@
//
// Integer Conversion.swift
// BigInt
//
// Created by Károly Lőrentey on 2017-08-11.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension BigUInt {
public init?<T: BinaryInteger>(exactly source: T) {
guard source >= (0 as T) else { return nil }
if source.bitWidth <= 2 * Word.bitWidth {
var it = source.words.makeIterator()
self.init(low: it.next() ?? 0, high: it.next() ?? 0)
precondition(it.next() == nil, "Length of BinaryInteger.words is greater than its bitWidth")
}
else {
self.init(words: source.words)
}
}
public init<T: BinaryInteger>(_ source: T) {
precondition(source >= (0 as T), "BigUInt cannot represent negative values")
self.init(exactly: source)!
}
public init<T: BinaryInteger>(truncatingIfNeeded source: T) {
self.init(words: source.words)
}
public init<T: BinaryInteger>(clamping source: T) {
if source <= (0 as T) {
self.init()
}
else {
self.init(words: source.words)
}
}
}
extension BigInt {
public init() {
self.init(sign: .plus, magnitude: 0)
}
/// Initializes a new signed big integer with the same value as the specified unsigned big integer.
public init(_ integer: BigUInt) {
self.magnitude = integer
self.sign = .plus
}
public init<T>(_ source: T) where T : BinaryInteger {
if source >= (0 as T) {
self.init(sign: .plus, magnitude: BigUInt(source))
}
else {
var words = Array(source.words)
words.twosComplement()
self.init(sign: .minus, magnitude: BigUInt(words: words))
}
}
public init?<T>(exactly source: T) where T : BinaryInteger {
self.init(source)
}
public init<T>(clamping source: T) where T : BinaryInteger {
self.init(source)
}
public init<T>(truncatingIfNeeded source: T) where T : BinaryInteger {
self.init(source)
}
}
extension BigUInt: ExpressibleByIntegerLiteral {
/// Initialize a new big integer from an integer literal.
public init(integerLiteral value: UInt64) {
self.init(value)
}
}
extension BigInt: ExpressibleByIntegerLiteral {
/// Initialize a new big integer from an integer literal.
public init(integerLiteral value: Int64) {
self.init(value)
}
}
@@ -0,0 +1,165 @@
//
// Multiplication.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension BigUInt {
//MARK: Multiplication
/// Multiply this big integer by a single word, and store the result in place of the original big integer.
///
/// - Complexity: O(count)
public mutating func multiply(byWord y: Word) {
guard y != 0 else { self = 0; return }
guard y != 1 else { return }
var carry: Word = 0
let c = self.count
for i in 0 ..< c {
let (h, l) = self[i].multipliedFullWidth(by: y)
let (low, o) = l.addingReportingOverflow(carry)
self[i] = low
carry = (o ? h + 1 : h)
}
self[c] = carry
}
/// Multiply this big integer by a single Word, and return the result.
///
/// - Complexity: O(count)
public func multiplied(byWord y: Word) -> BigUInt {
var r = self
r.multiply(byWord: y)
return r
}
/// Multiply `x` by `y`, and add the result to this integer, optionally shifted `shift` words to the left.
///
/// - Note: This is the fused multiply/shift/add operation; it is more efficient than doing the components
/// individually. (The fused operation doesn't need to allocate space for temporary big integers.)
/// `self` is set to `self + (x * y) << (shift * 2^Word.bitWidth)`
/// - Complexity: O(count)
public mutating func multiplyAndAdd(_ x: BigUInt, _ y: Word, shiftedBy shift: Int = 0) {
precondition(shift >= 0)
guard y != 0 && x.count > 0 else { return }
guard y != 1 else { self.add(x, shiftedBy: shift); return }
var mulCarry: Word = 0
var addCarry = false
let xc = x.count
var xi = 0
while xi < xc || addCarry || mulCarry > 0 {
let (h, l) = x[xi].multipliedFullWidth(by: y)
let (low, o) = l.addingReportingOverflow(mulCarry)
mulCarry = (o ? h + 1 : h)
let ai = shift + xi
let (sum1, so1) = self[ai].addingReportingOverflow(low)
if addCarry {
let (sum2, so2) = sum1.addingReportingOverflow(1)
self[ai] = sum2
addCarry = so1 || so2
}
else {
self[ai] = sum1
addCarry = so1
}
xi += 1
}
}
/// Multiply this integer by `y` and return the result.
///
/// - Note: This uses the naive O(n^2) multiplication algorithm unless both arguments have more than
/// `BigUInt.directMultiplicationLimit` words.
/// - Complexity: O(n^log2(3))
public func multiplied(by y: BigUInt) -> BigUInt {
// This method is mostly defined for symmetry with the rest of the arithmetic operations.
return self * y
}
/// Multiplication switches to an asymptotically better recursive algorithm when arguments have more words than this limit.
public static let directMultiplicationLimit: Int = 1024
/// Multiply `a` by `b` and return the result.
///
/// - Note: This uses the naive O(n^2) multiplication algorithm unless both arguments have more than
/// `BigUInt.directMultiplicationLimit` words.
/// - Complexity: O(n^log2(3))
public static func *(x: BigUInt, y: BigUInt) -> BigUInt {
let xc = x.count
let yc = y.count
if xc == 0 { return BigUInt() }
if yc == 0 { return BigUInt() }
if yc == 1 { return x.multiplied(byWord: y[0]) }
if xc == 1 { return y.multiplied(byWord: x[0]) }
if Swift.min(xc, yc) <= BigUInt.directMultiplicationLimit {
// Long multiplication.
let left = (xc < yc ? y : x)
let right = (xc < yc ? x : y)
var result = BigUInt()
for i in (0 ..< right.count).reversed() {
result.multiplyAndAdd(left, right[i], shiftedBy: i)
}
return result
}
if yc < xc {
let (xh, xl) = x.split
var r = xl * y
r.add(xh * y, shiftedBy: x.middleIndex)
return r
}
else if xc < yc {
let (yh, yl) = y.split
var r = yl * x
r.add(yh * x, shiftedBy: y.middleIndex)
return r
}
let shift = x.middleIndex
// Karatsuba multiplication:
// x * y = <a,b> * <c,d> = <ac, ac + bd - (a-b)(c-d), bd> (ignoring carry)
let (a, b) = x.split
let (c, d) = y.split
let high = a * c
let low = b * d
let xp = a >= b
let yp = c >= d
let xm = (xp ? a - b : b - a)
let ym = (yp ? c - d : d - c)
let m = xm * ym
var r = low
r.add(high, shiftedBy: 2 * shift)
r.add(low, shiftedBy: shift)
r.add(high, shiftedBy: shift)
if xp == yp {
r.subtract(m, shiftedBy: shift)
}
else {
r.add(m, shiftedBy: shift)
}
return r
}
/// Multiply `a` by `b` and store the result in `a`.
public static func *=(a: inout BigUInt, b: BigUInt) {
a = a * b
}
}
extension BigInt {
/// Multiply `a` with `b` and return the result.
public static func *(a: BigInt, b: BigInt) -> BigInt {
return BigInt(sign: a.sign == b.sign ? .plus : .minus, magnitude: a.magnitude * b.magnitude)
}
/// Multiply `a` with `b` in place.
public static func *=(a: inout BigInt, b: BigInt) { a = a * b }
}
@@ -0,0 +1,153 @@
//
// Prime Test.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-04.
// Copyright © 2016-2017 Károly Lőrentey.
//
/// The first several [prime numbers][primes].
///
/// [primes]: https://oeis.org/A000040
let primes: [BigUInt.Word] = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41]
/// The ith element in this sequence is the smallest composite number that passes the strong probable prime test
/// for all of the first (i+1) primes.
///
/// This is sequence [A014233](http://oeis.org/A014233) on the [Online Encyclopaedia of Integer Sequences](http://oeis.org).
let pseudoPrimes: [BigUInt] = [
/* 2 */ 2_047,
/* 3 */ 1_373_653,
/* 5 */ 25_326_001,
/* 7 */ 3_215_031_751,
/* 11 */ 2_152_302_898_747,
/* 13 */ 3_474_749_660_383,
/* 17 */ 341_550_071_728_321,
/* 19 */ 341_550_071_728_321,
/* 23 */ 3_825_123_056_546_413_051,
/* 29 */ 3_825_123_056_546_413_051,
/* 31 */ 3_825_123_056_546_413_051,
/* 37 */ "318665857834031151167461",
/* 41 */ "3317044064679887385961981",
]
extension BigUInt {
//MARK: Primality Testing
/// Returns true iff this integer passes the [strong probable prime test][sppt] for the specified base.
///
/// [sppt]: https://en.wikipedia.org/wiki/Probable_prime
public func isStrongProbablePrime(_ base: BigUInt) -> Bool {
precondition(base > (1 as BigUInt))
precondition(self > (0 as BigUInt))
let dec = self - 1
let r = dec.trailingZeroBitCount
let d = dec >> r
var test = base.power(d, modulus: self)
if test == 1 || test == dec { return true }
if r > 0 {
let shift = self.leadingZeroBitCount
let normalized = self << shift
for _ in 1 ..< r {
test *= test
test.formRemainder(dividingBy: normalized, normalizedBy: shift)
if test == 1 {
return false
}
if test == dec { return true }
}
}
return false
}
/// Returns true if this integer is probably prime. Returns false if this integer is definitely not prime.
///
/// This function performs a probabilistic [Miller-Rabin Primality Test][mrpt], consisting of `rounds` iterations,
/// each calculating the strong probable prime test for a random base. The number of rounds is 10 by default,
/// but you may specify your own choice.
///
/// To speed things up, the function checks if `self` is divisible by the first few prime numbers before
/// diving into (slower) Miller-Rabin testing.
///
/// Also, when `self` is less than 82 bits wide, `isPrime` does a deterministic test that is guaranteed to
/// return a correct result.
///
/// [mrpt]: https://en.wikipedia.org/wiki/MillerRabin_primality_test
public func isPrime(rounds: Int = 10) -> Bool {
if count <= 1 && self[0] < 2 { return false }
if count == 1 && self[0] < 4 { return true }
// Even numbers above 2 aren't prime.
if self[0] & 1 == 0 { return false }
// Quickly check for small primes.
for i in 1 ..< primes.count {
let p = primes[i]
if self.count == 1 && self[0] == p {
return true
}
if self.quotientAndRemainder(dividingByWord: p).remainder == 0 {
return false
}
}
/// Give an exact answer when we can.
if self < pseudoPrimes.last! {
for i in 0 ..< pseudoPrimes.count {
guard isStrongProbablePrime(BigUInt(primes[i])) else {
break
}
if self < pseudoPrimes[i] {
// `self` is below the lowest pseudoprime corresponding to the prime bases we tested. It's a prime!
return true
}
}
return false
}
/// Otherwise do as many rounds of random SPPT as required.
for _ in 0 ..< rounds {
let random = BigUInt.randomInteger(lessThan: self - 2) + 2
guard isStrongProbablePrime(random) else {
return false
}
}
// Well, it smells primey to me.
return true
}
}
extension BigInt {
//MARK: Primality Testing
/// Returns true iff this integer passes the [strong probable prime test][sppt] for the specified base.
///
/// [sppt]: https://en.wikipedia.org/wiki/Probable_prime
public func isStrongProbablePrime(_ base: BigInt) -> Bool {
precondition(base.sign == .plus)
if self.sign == .minus { return false }
return self.magnitude.isStrongProbablePrime(base.magnitude)
}
/// Returns true if this integer is probably prime. Returns false if this integer is definitely not prime.
///
/// This function performs a probabilistic [Miller-Rabin Primality Test][mrpt], consisting of `rounds` iterations,
/// each calculating the strong probable prime test for a random base. The number of rounds is 10 by default,
/// but you may specify your own choice.
///
/// To speed things up, the function checks if `self` is divisible by the first few prime numbers before
/// diving into (slower) Miller-Rabin testing.
///
/// Also, when `self` is less than 82 bits wide, `isPrime` does a deterministic test that is guaranteed to
/// return a correct result.
///
/// [mrpt]: https://en.wikipedia.org/wiki/MillerRabin_primality_test
public func isPrime(rounds: Int = 10) -> Bool {
if self.sign == .minus { return false }
return self.magnitude.isPrime(rounds: rounds)
}
}
@@ -0,0 +1,101 @@
//
// Random.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-04.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension BigUInt {
/// Create a big unsigned integer consisting of `width` uniformly distributed random bits.
///
/// - Parameter width: The maximum number of one bits in the result.
/// - Parameter generator: The source of randomness.
/// - Returns: A big unsigned integer less than `1 << width`.
public static func randomInteger<RNG: RandomNumberGenerator>(withMaximumWidth width: Int, using generator: inout RNG) -> BigUInt {
var result = BigUInt.zero
var bitsLeft = width
var i = 0
let wordsNeeded = (width + Word.bitWidth - 1) / Word.bitWidth
if wordsNeeded > 2 {
result.reserveCapacity(wordsNeeded)
}
while bitsLeft >= Word.bitWidth {
result[i] = generator.next()
i += 1
bitsLeft -= Word.bitWidth
}
if bitsLeft > 0 {
let mask: Word = (1 << bitsLeft) - 1
result[i] = (generator.next() as Word) & mask
}
return result
}
/// Create a big unsigned integer consisting of `width` uniformly distributed random bits.
///
/// - Note: I use a `SystemRandomGeneratorGenerator` as the source of randomness.
///
/// - Parameter width: The maximum number of one bits in the result.
/// - Returns: A big unsigned integer less than `1 << width`.
public static func randomInteger(withMaximumWidth width: Int) -> BigUInt {
var rng = SystemRandomNumberGenerator()
return randomInteger(withMaximumWidth: width, using: &rng)
}
/// Create a big unsigned integer consisting of `width-1` uniformly distributed random bits followed by a one bit.
///
/// - Note: If `width` is zero, the result is zero.
///
/// - Parameter width: The number of bits required to represent the answer.
/// - Parameter generator: The source of randomness.
/// - Returns: A random big unsigned integer whose width is `width`.
public static func randomInteger<RNG: RandomNumberGenerator>(withExactWidth width: Int, using generator: inout RNG) -> BigUInt {
// width == 0 -> return 0 because there is no room for a one bit.
// width == 1 -> return 1 because there is no room for any random bits.
guard width > 1 else { return BigUInt(width) }
var result = randomInteger(withMaximumWidth: width - 1, using: &generator)
result[(width - 1) / Word.bitWidth] |= 1 << Word((width - 1) % Word.bitWidth)
return result
}
/// Create a big unsigned integer consisting of `width-1` uniformly distributed random bits followed by a one bit.
///
/// - Note: If `width` is zero, the result is zero.
/// - Note: I use a `SystemRandomGeneratorGenerator` as the source of randomness.
///
/// - Returns: A random big unsigned integer whose width is `width`.
public static func randomInteger(withExactWidth width: Int) -> BigUInt {
var rng = SystemRandomNumberGenerator()
return randomInteger(withExactWidth: width, using: &rng)
}
/// Create a uniformly distributed random unsigned integer that's less than the specified limit.
///
/// - Precondition: `limit > 0`.
///
/// - Parameter limit: The upper bound on the result.
/// - Parameter generator: The source of randomness.
/// - Returns: A random big unsigned integer that is less than `limit`.
public static func randomInteger<RNG: RandomNumberGenerator>(lessThan limit: BigUInt, using generator: inout RNG) -> BigUInt {
precondition(limit > 0, "\(#function): 0 is not a valid limit")
let width = limit.bitWidth
var random = randomInteger(withMaximumWidth: width, using: &generator)
while random >= limit {
random = randomInteger(withMaximumWidth: width, using: &generator)
}
return random
}
/// Create a uniformly distributed random unsigned integer that's less than the specified limit.
///
/// - Precondition: `limit > 0`.
/// - Note: I use a `SystemRandomGeneratorGenerator` as the source of randomness.
///
/// - Parameter limit: The upper bound on the result.
/// - Returns: A random big unsigned integer that is less than `limit`.
public static func randomInteger(lessThan limit: BigUInt) -> BigUInt {
var rng = SystemRandomNumberGenerator()
return randomInteger(lessThan: limit, using: &rng)
}
}
@@ -0,0 +1,211 @@
//
// Shifts.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension BigUInt {
//MARK: Shift Operators
internal func shiftedLeft(by amount: Word) -> BigUInt {
guard amount > 0 else { return self }
let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words)
let up = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift)
let down = Word(Word.bitWidth) - up
var result = BigUInt()
if up > 0 {
var i = 0
var lowbits: Word = 0
while i < self.count || lowbits > 0 {
let word = self[i]
result[i + ext] = word << up | lowbits
lowbits = word >> down
i += 1
}
}
else {
for i in 0 ..< self.count {
result[i + ext] = self[i]
}
}
return result
}
internal mutating func shiftLeft(by amount: Word) {
guard amount > 0 else { return }
let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words)
let up = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift)
let down = Word(Word.bitWidth) - up
if up > 0 {
var i = 0
var lowbits: Word = 0
while i < self.count || lowbits > 0 {
let word = self[i]
self[i] = word << up | lowbits
lowbits = word >> down
i += 1
}
}
if ext > 0 && self.count > 0 {
self.shiftLeft(byWords: ext)
}
}
internal func shiftedRight(by amount: Word) -> BigUInt {
guard amount > 0 else { return self }
guard amount < self.bitWidth else { return 0 }
let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words)
let down = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift)
let up = Word(Word.bitWidth) - down
var result = BigUInt()
if down > 0 {
var highbits: Word = 0
for i in (ext ..< self.count).reversed() {
let word = self[i]
result[i - ext] = highbits | word >> down
highbits = word << up
}
}
else {
for i in (ext ..< self.count).reversed() {
result[i - ext] = self[i]
}
}
return result
}
internal mutating func shiftRight(by amount: Word) {
guard amount > 0 else { return }
guard amount < self.bitWidth else { self.clear(); return }
let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words)
let down = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift)
let up = Word(Word.bitWidth) - down
if ext > 0 {
self.shiftRight(byWords: ext)
}
if down > 0 {
var i = self.count - 1
var highbits: Word = 0
while i >= 0 {
let word = self[i]
self[i] = highbits | word >> down
highbits = word << up
i -= 1
}
}
}
public static func >>=<Other: BinaryInteger>(lhs: inout BigUInt, rhs: Other) {
if rhs < (0 as Other) {
lhs <<= (0 - rhs)
}
else if rhs >= lhs.bitWidth {
lhs.clear()
}
else {
lhs.shiftRight(by: UInt(rhs))
}
}
public static func <<=<Other: BinaryInteger>(lhs: inout BigUInt, rhs: Other) {
if rhs < (0 as Other) {
lhs >>= (0 - rhs)
return
}
lhs.shiftLeft(by: Word(exactly: rhs)!)
}
public static func >><Other: BinaryInteger>(lhs: BigUInt, rhs: Other) -> BigUInt {
if rhs < (0 as Other) {
return lhs << (0 - rhs)
}
if rhs > Word.max {
return 0
}
return lhs.shiftedRight(by: UInt(rhs))
}
public static func <<<Other: BinaryInteger>(lhs: BigUInt, rhs: Other) -> BigUInt {
if rhs < (0 as Other) {
return lhs >> (0 - rhs)
}
return lhs.shiftedLeft(by: Word(exactly: rhs)!)
}
}
extension BigInt {
func shiftedLeft(by amount: Word) -> BigInt {
return BigInt(sign: self.sign, magnitude: self.magnitude.shiftedLeft(by: amount))
}
mutating func shiftLeft(by amount: Word) {
self.magnitude.shiftLeft(by: amount)
}
func shiftedRight(by amount: Word) -> BigInt {
let m = self.magnitude.shiftedRight(by: amount)
return BigInt(sign: self.sign, magnitude: self.sign == .minus && m.isZero ? 1 : m)
}
mutating func shiftRight(by amount: Word) {
magnitude.shiftRight(by: amount)
if sign == .minus, magnitude.isZero {
magnitude.load(1)
}
}
public static func &<<(left: BigInt, right: BigInt) -> BigInt {
return left.shiftedLeft(by: right.words[0])
}
public static func &<<=(left: inout BigInt, right: BigInt) {
left.shiftLeft(by: right.words[0])
}
public static func &>>(left: BigInt, right: BigInt) -> BigInt {
return left.shiftedRight(by: right.words[0])
}
public static func &>>=(left: inout BigInt, right: BigInt) {
left.shiftRight(by: right.words[0])
}
public static func <<<Other: BinaryInteger>(lhs: BigInt, rhs: Other) -> BigInt {
guard rhs >= (0 as Other) else { return lhs >> (0 - rhs) }
return lhs.shiftedLeft(by: Word(rhs))
}
public static func <<=<Other: BinaryInteger>(lhs: inout BigInt, rhs: Other) {
if rhs < (0 as Other) {
lhs >>= (0 - rhs)
}
else {
lhs.shiftLeft(by: Word(rhs))
}
}
public static func >><Other: BinaryInteger>(lhs: BigInt, rhs: Other) -> BigInt {
guard rhs >= (0 as Other) else { return lhs << (0 - rhs) }
return lhs.shiftedRight(by: Word(rhs))
}
public static func >>=<Other: BinaryInteger>(lhs: inout BigInt, rhs: Other) {
if rhs < (0 as Other) {
lhs <<= (0 - rhs)
}
else {
lhs.shiftRight(by: Word(rhs))
}
}
}
@@ -0,0 +1,41 @@
//
// Square Root.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
//MARK: Square Root
extension BigUInt {
/// Returns the integer square root of a big integer; i.e., the largest integer whose square isn't greater than `value`.
///
/// - Returns: floor(sqrt(self))
public func squareRoot() -> BigUInt {
// This implementation uses Newton's method.
guard !self.isZero else { return BigUInt() }
var x = BigUInt(1) << ((self.bitWidth + 1) / 2)
var y: BigUInt = 0
while true {
y.load(self)
y /= x
y += x
y >>= 1
if x == y || x == y - 1 { break }
x = y
}
return x
}
}
extension BigInt {
/// Returns the integer square root of a big integer; i.e., the largest integer whose square isn't greater than `value`.
///
/// - Requires: self >= 0
/// - Returns: floor(sqrt(self))
public func squareRoot() -> BigInt {
precondition(self.sign == .plus)
return BigInt(sign: .plus, magnitude: self.magnitude.squareRoot())
}
}
@@ -0,0 +1,38 @@
//
// Strideable.swift
// BigInt
//
// Created by Károly Lőrentey on 2017-08-11.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension BigUInt: Strideable {
/// A type that can represent the distance between two values ofa `BigUInt`.
public typealias Stride = BigInt
/// Adds `n` to `self` and returns the result. Traps if the result would be less than zero.
public func advanced(by n: BigInt) -> BigUInt {
return n.sign == .minus ? self - n.magnitude : self + n.magnitude
}
/// Returns the (potentially negative) difference between `self` and `other` as a `BigInt`. Never traps.
public func distance(to other: BigUInt) -> BigInt {
return BigInt(other) - BigInt(self)
}
}
extension BigInt: Strideable {
public typealias Stride = BigInt
/// Returns `self + n`.
public func advanced(by n: Stride) -> BigInt {
return self + n
}
/// Returns `other - self`.
public func distance(to other: BigInt) -> Stride {
return other - self
}
}
@@ -0,0 +1,254 @@
//
// String Conversion.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension BigUInt {
//MARK: String Conversion
/// Calculates the number of numerals in a given radix that fit inside a single `Word`.
///
/// - Returns: (chars, power) where `chars` is highest that satisfy `radix^chars <= 2^Word.bitWidth`. `power` is zero
/// if radix is a power of two; otherwise `power == radix^chars`.
fileprivate static func charsPerWord(forRadix radix: Int) -> (chars: Int, power: Word) {
var power: Word = 1
var overflow = false
var count = 0
while !overflow {
let (high,low) = power.multipliedFullWidth(by: Word(radix))
if high > 0 {
overflow = true
}
if !overflow || (high == 1 && low == 0) {
count += 1
power = low
}
}
return (count, power)
}
/// Initialize a big integer from an ASCII representation in a given radix. Numerals above `9` are represented by
/// letters from the English alphabet.
///
/// - Requires: `radix > 1 && radix < 36`
/// - Parameter `text`: A string consisting of characters corresponding to numerals in the given radix. (0-9, a-z, A-Z)
/// - Parameter `radix`: The base of the number system to use, or 10 if unspecified.
/// - Returns: The integer represented by `text`, or nil if `text` contains a character that does not represent a numeral in `radix`.
public init?<S: StringProtocol>(_ text: S, radix: Int = 10) {
precondition(radix > 1 && radix < 36)
guard !text.isEmpty else { return nil }
let (charsPerWord, power) = BigUInt.charsPerWord(forRadix: radix)
var words: [Word] = []
var end = text.endIndex
var start = end
var count = 0
while start != text.startIndex {
start = text.index(before: start)
count += 1
if count == charsPerWord {
guard let d = Word.init(text[start ..< end], radix: radix) else { return nil }
words.append(d)
end = start
count = 0
}
}
if start != end {
guard let d = Word.init(text[start ..< end], radix: radix) else { return nil }
words.append(d)
}
if power == 0 {
self.init(words: words)
}
else {
self.init()
for d in words.reversed() {
self.multiply(byWord: power)
self.addWord(d)
}
}
}
}
extension BigInt {
/// Initialize a big integer from an ASCII representation in a given radix. Numerals above `9` are represented by
/// letters from the English alphabet.
///
/// - Requires: `radix > 1 && radix < 36`
/// - Parameter `text`: A string optionally starting with "-" or "+" followed by characters corresponding to numerals in the given radix. (0-9, a-z, A-Z)
/// - Parameter `radix`: The base of the number system to use, or 10 if unspecified.
/// - Returns: The integer represented by `text`, or nil if `text` contains a character that does not represent a numeral in `radix`.
public init?<S: StringProtocol>(_ text: S, radix: Int = 10) {
var magnitude: BigUInt?
var sign: Sign = .plus
if text.first == "-" {
sign = .minus
let text = text.dropFirst()
magnitude = BigUInt(text, radix: radix)
}
else if text.first == "+" {
let text = text.dropFirst()
magnitude = BigUInt(text, radix: radix)
}
else {
magnitude = BigUInt(text, radix: radix)
}
guard let m = magnitude else { return nil }
self.magnitude = m
self.sign = m.isZero ? .plus : sign
}
}
extension String {
/// Initialize a new string with the base-10 representation of an unsigned big integer.
///
/// - Complexity: O(v.count^2)
public init(_ v: BigUInt) { self.init(v, radix: 10, uppercase: false) }
/// Initialize a new string representing an unsigned big integer in the given radix (base).
///
/// Numerals greater than 9 are represented as letters from the English alphabet,
/// starting with `a` if `uppercase` is false or `A` otherwise.
///
/// - Requires: radix > 1 && radix <= 36
/// - Complexity: O(count) when radix is a power of two; otherwise O(count^2).
public init(_ v: BigUInt, radix: Int, uppercase: Bool = false) {
precondition(radix > 1)
let (charsPerWord, power) = BigUInt.charsPerWord(forRadix: radix)
guard !v.isZero else { self = "0"; return }
var parts: [String]
if power == 0 {
parts = v.words.map { String($0, radix: radix, uppercase: uppercase) }
}
else {
parts = []
var rest = v
while !rest.isZero {
let mod = rest.divide(byWord: power)
parts.append(String(mod, radix: radix, uppercase: uppercase))
}
}
assert(!parts.isEmpty)
self = ""
var first = true
for part in parts.reversed() {
let zeroes = charsPerWord - part.count
assert(zeroes >= 0)
if !first && zeroes > 0 {
// Insert leading zeroes for mid-Words
self += String(repeating: "0", count: zeroes)
}
first = false
self += part
}
}
/// Initialize a new string representing a signed big integer in the given radix (base).
///
/// Numerals greater than 9 are represented as letters from the English alphabet,
/// starting with `a` if `uppercase` is false or `A` otherwise.
///
/// - Requires: radix > 1 && radix <= 36
/// - Complexity: O(count) when radix is a power of two; otherwise O(count^2).
public init(_ value: BigInt, radix: Int = 10, uppercase: Bool = false) {
self = String(value.magnitude, radix: radix, uppercase: uppercase)
if value.sign == .minus {
self = "-" + self
}
}
}
extension BigUInt: ExpressibleByStringLiteral {
/// Initialize a new big integer from a Unicode scalar.
/// The scalar must represent a decimal digit.
public init(unicodeScalarLiteral value: UnicodeScalar) {
self = BigUInt(String(value), radix: 10)!
}
/// Initialize a new big integer from an extended grapheme cluster.
/// The cluster must consist of a decimal digit.
public init(extendedGraphemeClusterLiteral value: String) {
self = BigUInt(value, radix: 10)!
}
/// Initialize a new big integer from a decimal number represented by a string literal of arbitrary length.
/// The string must contain only decimal digits.
public init(stringLiteral value: StringLiteralType) {
self = BigUInt(value, radix: 10)!
}
}
extension BigInt: ExpressibleByStringLiteral {
/// Initialize a new big integer from a Unicode scalar.
/// The scalar must represent a decimal digit.
public init(unicodeScalarLiteral value: UnicodeScalar) {
self = BigInt(String(value), radix: 10)!
}
/// Initialize a new big integer from an extended grapheme cluster.
/// The cluster must consist of a decimal digit.
public init(extendedGraphemeClusterLiteral value: String) {
self = BigInt(value, radix: 10)!
}
/// Initialize a new big integer from a decimal number represented by a string literal of arbitrary length.
/// The string must contain only decimal digits.
public init(stringLiteral value: StringLiteralType) {
self = BigInt(value, radix: 10)!
}
}
extension BigUInt: CustomStringConvertible {
/// Return the decimal representation of this integer.
public var description: String {
return String(self, radix: 10)
}
}
extension BigInt: CustomStringConvertible {
/// Return the decimal representation of this integer.
public var description: String {
return String(self, radix: 10)
}
}
extension BigUInt: CustomDebugStringConvertible {
/// Return the decimal representation of this integer.
public var debugDescription: String {
let text = String(self)
return text + " (\(self.bitWidth) bits)"
}
}
extension BigInt: CustomDebugStringConvertible {
/// Return the decimal representation of this integer.
public var debugDescription: String {
let text = String(self)
return text + " (\(self.magnitude.bitWidth) bits)"
}
}
extension BigUInt: CustomPlaygroundDisplayConvertible {
/// Return the playground quick look representation of this integer.
public var playgroundDescription: Any {
debugDescription
}
}
extension BigInt: CustomPlaygroundDisplayConvertible {
/// Return the playground quick look representation of this integer.
public var playgroundDescription: Any {
debugDescription
}
}
@@ -0,0 +1,169 @@
//
// Subtraction.swift
// BigInt
//
// Created by Károly Lőrentey on 2016-01-03.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension BigUInt {
//MARK: Subtraction
/// Subtract `word` from this integer in place, returning a flag indicating if the operation
/// caused an arithmetic overflow. `word` is shifted `shift` words to the left before being subtracted.
///
/// - Note: If the result indicates an overflow, then `self` becomes the two's complement of the absolute difference.
/// - Complexity: O(count)
internal mutating func subtractWordReportingOverflow(_ word: Word, shiftedBy shift: Int = 0) -> Bool {
precondition(shift >= 0)
var carry: Word = word
var i = shift
let count = self.count
while carry > 0 && i < count {
let (d, c) = self[i].subtractingReportingOverflow(carry)
self[i] = d
carry = (c ? 1 : 0)
i += 1
}
return carry > 0
}
/// Subtract `word` from this integer, returning the difference and a flag that is true if the operation
/// caused an arithmetic overflow. `word` is shifted `shift` words to the left before being subtracted.
///
/// - Note: If `overflow` is true, then the returned value is the two's complement of the absolute difference.
/// - Complexity: O(count)
internal func subtractingWordReportingOverflow(_ word: Word, shiftedBy shift: Int = 0) -> (partialValue: BigUInt, overflow: Bool) {
var result = self
let overflow = result.subtractWordReportingOverflow(word, shiftedBy: shift)
return (result, overflow)
}
/// Subtract a digit `d` from this integer in place.
/// `d` is shifted `shift` digits to the left before being subtracted.
///
/// - Requires: self >= d * 2^shift
/// - Complexity: O(count)
internal mutating func subtractWord(_ word: Word, shiftedBy shift: Int = 0) {
let overflow = subtractWordReportingOverflow(word, shiftedBy: shift)
precondition(!overflow)
}
/// Subtract a digit `d` from this integer and return the result.
/// `d` is shifted `shift` digits to the left before being subtracted.
///
/// - Requires: self >= d * 2^shift
/// - Complexity: O(count)
internal func subtractingWord(_ word: Word, shiftedBy shift: Int = 0) -> BigUInt {
var result = self
result.subtractWord(word, shiftedBy: shift)
return result
}
/// Subtract `other` from this integer in place, and return a flag indicating if the operation caused an
/// arithmetic overflow. `other` is shifted `shift` digits to the left before being subtracted.
///
/// - Note: If the result indicates an overflow, then `self` becomes the twos' complement of the absolute difference.
/// - Complexity: O(count)
public mutating func subtractReportingOverflow(_ b: BigUInt, shiftedBy shift: Int = 0) -> Bool {
precondition(shift >= 0)
var carry = false
var bi = 0
let bc = b.count
let count = self.count
while bi < bc || (shift + bi < count && carry) {
let ai = shift + bi
let (d, c) = self[ai].subtractingReportingOverflow(b[bi])
if carry {
let (d2, c2) = d.subtractingReportingOverflow(1)
self[ai] = d2
carry = c || c2
}
else {
self[ai] = d
carry = c
}
bi += 1
}
return carry
}
/// Subtract `other` from this integer, returning the difference and a flag indicating arithmetic overflow.
/// `other` is shifted `shift` digits to the left before being subtracted.
///
/// - Note: If `overflow` is true, then the result value is the twos' complement of the absolute value of the difference.
/// - Complexity: O(count)
public func subtractingReportingOverflow(_ other: BigUInt, shiftedBy shift: Int) -> (partialValue: BigUInt, overflow: Bool) {
var result = self
let overflow = result.subtractReportingOverflow(other, shiftedBy: shift)
return (result, overflow)
}
/// Subtracts `other` from `self`, returning the result and a flag indicating arithmetic overflow.
///
/// - Note: When the operation overflows, then `partialValue` is the twos' complement of the absolute value of the difference.
/// - Complexity: O(count)
public func subtractingReportingOverflow(_ other: BigUInt) -> (partialValue: BigUInt, overflow: Bool) {
return self.subtractingReportingOverflow(other, shiftedBy: 0)
}
/// Subtract `other` from this integer in place.
/// `other` is shifted `shift` digits to the left before being subtracted.
///
/// - Requires: self >= other * 2^shift
/// - Complexity: O(count)
public mutating func subtract(_ other: BigUInt, shiftedBy shift: Int = 0) {
let overflow = subtractReportingOverflow(other, shiftedBy: shift)
precondition(!overflow)
}
/// Subtract `b` from this integer, and return the difference.
/// `b` is shifted `shift` digits to the left before being subtracted.
///
/// - Requires: self >= b * 2^shift
/// - Complexity: O(count)
public func subtracting(_ other: BigUInt, shiftedBy shift: Int = 0) -> BigUInt {
var result = self
result.subtract(other, shiftedBy: shift)
return result
}
/// Decrement this integer by one.
///
/// - Requires: !isZero
/// - Complexity: O(count)
public mutating func decrement(shiftedBy shift: Int = 0) {
self.subtract(1, shiftedBy: shift)
}
/// Subtract `b` from `a` and return the result.
///
/// - Requires: a >= b
/// - Complexity: O(a.count)
public static func -(a: BigUInt, b: BigUInt) -> BigUInt {
return a.subtracting(b)
}
/// Subtract `b` from `a` and store the result in `a`.
///
/// - Requires: a >= b
/// - Complexity: O(a.count)
public static func -=(a: inout BigUInt, b: BigUInt) {
a.subtract(b)
}
}
extension BigInt {
public mutating func negate() {
guard !magnitude.isZero else { return }
self.sign = self.sign == .plus ? .minus : .plus
}
/// Subtract `b` from `a` and return the result.
public static func -(a: BigInt, b: BigInt) -> BigInt {
return a + -b
}
/// Subtract `b` from `a` in place.
public static func -=(a: inout BigInt, b: BigInt) { a = a - b }
}
@@ -0,0 +1,202 @@
//
// Words and Bits.swift
// BigInt
//
// Created by Károly Lőrentey on 2017-08-11.
// Copyright © 2016-2017 Károly Lőrentey.
//
extension Array where Element == UInt {
mutating func twosComplement() {
var increment = true
for i in 0 ..< self.count {
if increment {
(self[i], increment) = (~self[i]).addingReportingOverflow(1)
}
else {
self[i] = ~self[i]
}
}
}
}
extension BigUInt {
public subscript(bitAt index: Int) -> Bool {
get {
precondition(index >= 0)
let (i, j) = index.quotientAndRemainder(dividingBy: Word.bitWidth)
return self[i] & (1 << j) != 0
}
set {
precondition(index >= 0)
let (i, j) = index.quotientAndRemainder(dividingBy: Word.bitWidth)
if newValue {
self[i] |= 1 << j
}
else {
self[i] &= ~(1 << j)
}
}
}
}
extension BigUInt {
/// The minimum number of bits required to represent this integer in binary.
///
/// - Returns: floor(log2(2 * self + 1))
/// - Complexity: O(1)
public var bitWidth: Int {
guard count > 0 else { return 0 }
return count * Word.bitWidth - self[count - 1].leadingZeroBitCount
}
/// The number of leading zero bits in the binary representation of this integer in base `2^(Word.bitWidth)`.
/// This is useful when you need to normalize a `BigUInt` such that the top bit of its most significant word is 1.
///
/// - Note: 0 is considered to have zero leading zero bits.
/// - Returns: A value in `0...(Word.bitWidth - 1)`.
/// - SeeAlso: width
/// - Complexity: O(1)
public var leadingZeroBitCount: Int {
guard count > 0 else { return 0 }
return self[count - 1].leadingZeroBitCount
}
/// The number of trailing zero bits in the binary representation of this integer.
///
/// - Note: 0 is considered to have zero trailing zero bits.
/// - Returns: A value in `0...width`.
/// - Complexity: O(count)
public var trailingZeroBitCount: Int {
guard count > 0 else { return 0 }
let i = self.words.firstIndex { $0 != 0 }!
return i * Word.bitWidth + self[i].trailingZeroBitCount
}
}
extension BigInt {
public var bitWidth: Int {
guard !magnitude.isZero else { return 0 }
return magnitude.bitWidth + 1
}
public var trailingZeroBitCount: Int {
// Amazingly, this works fine for negative numbers
return magnitude.trailingZeroBitCount
}
}
extension BigUInt {
public struct Words: RandomAccessCollection {
private let value: BigUInt
fileprivate init(_ value: BigUInt) { self.value = value }
public var startIndex: Int { return 0 }
public var endIndex: Int { return value.count }
public subscript(_ index: Int) -> Word {
return value[index]
}
}
public var words: Words { return Words(self) }
public init<Words: Sequence>(words: Words) where Words.Element == Word {
let uc = words.underestimatedCount
if uc > 2 {
self.init(words: Array(words))
}
else {
var it = words.makeIterator()
guard let w0 = it.next() else {
self.init()
return
}
guard let w1 = it.next() else {
self.init(word: w0)
return
}
if let w2 = it.next() {
var words: [UInt] = []
words.reserveCapacity(Swift.max(3, uc))
words.append(w0)
words.append(w1)
words.append(w2)
while let word = it.next() {
words.append(word)
}
self.init(words: words)
}
else {
self.init(low: w0, high: w1)
}
}
}
}
extension BigInt {
public struct Words: RandomAccessCollection {
public typealias Indices = CountableRange<Int>
private let value: BigInt
private let decrementLimit: Int
fileprivate init(_ value: BigInt) {
self.value = value
switch value.sign {
case .plus:
self.decrementLimit = 0
case .minus:
assert(!value.magnitude.isZero)
self.decrementLimit = value.magnitude.words.firstIndex(where: { $0 != 0 })!
}
}
public var count: Int {
switch value.sign {
case .plus:
if let high = value.magnitude.words.last, high >> (Word.bitWidth - 1) != 0 {
return value.magnitude.count + 1
}
return value.magnitude.count
case .minus:
let high = value.magnitude.words.last!
if high >> (Word.bitWidth - 1) != 0 {
return value.magnitude.count + 1
}
return value.magnitude.count
}
}
public var indices: Indices { return 0 ..< count }
public var startIndex: Int { return 0 }
public var endIndex: Int { return count }
public subscript(_ index: Int) -> UInt {
// Note that indices above `endIndex` are accepted.
if value.sign == .plus {
return value.magnitude[index]
}
if index <= decrementLimit {
return ~(value.magnitude[index] &- 1)
}
return ~value.magnitude[index]
}
}
public var words: Words {
return Words(self)
}
public init<S: Sequence>(words: S) where S.Element == Word {
var words = Array(words)
if (words.last ?? 0) >> (Word.bitWidth - 1) == 0 {
self.init(sign: .plus, magnitude: BigUInt(words: words))
}
else {
words.twosComplement()
self.init(sign: .minus, magnitude: BigUInt(words: words))
}
}
}
@@ -0,0 +1,173 @@
//
// Models.swift
// PortalKit
//
// Data types and result models for PortalClient operations.
//
import Foundation
public enum PortalClientError: LocalizedError, Sendable {
case invalidHost(String)
case invalidURL(String)
case pairingFailed(String)
case notPaired
case tlsPinningMismatch(expected: String, got: String)
case requestFailed(statusCode: Int, message: String)
case communicationError(String)
case jsonParsingError(String)
public var errorDescription: String? {
switch self {
case .invalidHost(let h): return "Invalid Portal host: \(h)"
case .invalidURL(let u): return "Invalid URL: \(u)"
case .pairingFailed(let m): return "Pairing failed: \(m)"
case .notPaired: return "Portal is not paired. Please pair first or specify credentials."
case .tlsPinningMismatch:
return "Certificate mismatch — connection rejected. Unpair and pair again if this is your Portal."
case .requestFailed(let code, let msg):
return "HTTP request failed with status \(code): \(msg)"
case .communicationError(let msg):
return "Network communication error: \(msg)"
case .jsonParsingError(let msg):
return "JSON parsing error: \(msg)"
}
}
}
public struct PortalStatus: Sendable {
public let host: String
public let isOnline: Bool
public let isPaired: Bool
public let pinnedCertSha256: String?
public let leafCertSha256: String?
public let isCertPinMatching: Bool
public let serverReportedCertSha256: String?
public let details: String
public init(
host: String,
isOnline: Bool,
isPaired: Bool,
pinnedCertSha256: String?,
leafCertSha256: String?,
isCertPinMatching: Bool,
serverReportedCertSha256: String?,
details: String
) {
self.host = host
self.isOnline = isOnline
self.isPaired = isPaired
self.pinnedCertSha256 = pinnedCertSha256
self.leafCertSha256 = leafCertSha256
self.isCertPinMatching = isCertPinMatching
self.serverReportedCertSha256 = serverReportedCertSha256
self.details = details
}
}
/// Camera mode + mode-specific config from `/control/state` and SSE `/control/events`.
public struct PortalCameraState: Codable, Sendable, Equatable {
public let mode: String
public let config: PortalCameraConfig
public init(mode: String, config: PortalCameraConfig = PortalCameraConfig()) {
self.mode = mode
self.config = config
}
}
public struct PortalCameraConfig: Codable, Sendable, Equatable {
public let centerX: Double?
public let centerY: Double?
public let scale: Double?
public let framingTightness: Double?
public let trackingResponseDelayPct: Double?
public let trackingSensitivityPct: Double?
public let transitionSpeedPct: Double?
public init(
centerX: Double? = nil,
centerY: Double? = nil,
scale: Double? = nil,
framingTightness: Double? = nil,
trackingResponseDelayPct: Double? = nil,
trackingSensitivityPct: Double? = nil,
transitionSpeedPct: Double? = nil
) {
self.centerX = centerX
self.centerY = centerY
self.scale = scale
self.framingTightness = framingTightness
self.trackingResponseDelayPct = trackingResponseDelayPct
self.trackingSensitivityPct = trackingSensitivityPct
self.transitionSpeedPct = transitionSpeedPct
}
}
public struct PairingSession: Sendable {
public let pairingId: String
public let saltHex: String
public let pubBHex: String
public let expiresIn: Int?
public let capturedCertSha256: Data
public let capturedCertSha256Hex: String
public let srpClient: PortalSrpClient
public init(
pairingId: String,
saltHex: String,
pubBHex: String,
expiresIn: Int?,
capturedCertSha256: Data,
capturedCertSha256Hex: String,
srpClient: PortalSrpClient
) {
self.pairingId = pairingId
self.saltHex = saltHex
self.pubBHex = pubBHex
self.expiresIn = expiresIn
self.capturedCertSha256 = capturedCertSha256
self.capturedCertSha256Hex = capturedCertSha256Hex
self.srpClient = srpClient
}
}
public struct PairingResult: Sendable {
public let success: Bool
public let token: String
public let pinnedCertSha256Hex: String
public let serverM2Hex: String
public init(success: Bool, token: String, pinnedCertSha256Hex: String, serverM2Hex: String) {
self.success = success
self.token = token
self.pinnedCertSha256Hex = pinnedCertSha256Hex
self.serverM2Hex = serverM2Hex
}
}
public struct MitmDefenseResult: Sendable {
public let attackDescription: String
public let simulatedMitmCertSha256Hex: String
public let realCertSha256Hex: String
public let defenseSuccessful: Bool
public let serverRejectionMessage: String
public let attemptsRemaining: Int?
public init(
attackDescription: String,
simulatedMitmCertSha256Hex: String,
realCertSha256Hex: String,
defenseSuccessful: Bool,
serverRejectionMessage: String,
attemptsRemaining: Int?
) {
self.attackDescription = attackDescription
self.simulatedMitmCertSha256Hex = simulatedMitmCertSha256Hex
self.realCertSha256Hex = realCertSha256Hex
self.defenseSuccessful = defenseSuccessful
self.serverRejectionMessage = serverRejectionMessage
self.attemptsRemaining = attemptsRemaining
}
}
@@ -0,0 +1,475 @@
//
// PortalClient.swift
// PortalKit
//
// High-level client for pairing, status, and control with Portal TV.
//
import Foundation
import CryptoKit
public final class PortalClient: @unchecked Sendable {
public let host: String
public let credentialStorage: CredentialStorage
public let requestTimeout: TimeInterval
public init(
host: String,
credentialStorage: CredentialStorage = PortalAuth.defaultStorage,
requestTimeout: TimeInterval = 10
) {
// Normalize host: strip leading scheme if present, default PortalCam port if missing
var clean = host.trimmingCharacters(in: .whitespacesAndNewlines)
if clean.hasPrefix("https://") {
clean = String(clean.dropFirst("https://".count))
} else if clean.hasPrefix("http://") {
clean = String(clean.dropFirst("http://".count))
}
if clean.hasSuffix("/") {
clean = String(clean.dropLast())
}
if !clean.contains(":") {
clean = "\(clean):\(PortalEndpoints.port)"
}
self.host = clean
self.credentialStorage = credentialStorage
self.requestTimeout = requestTimeout
}
private var baseURLString: String {
"https://\(host)"
}
// MARK: - Status & Inspection
/// Checks service status, queries /auth/cert, and verifies TLS certificate pinning state.
public func status() async throws -> PortalStatus {
guard let url = URL(string: "\(baseURLString)/auth/cert") else {
throw PortalClientError.invalidHost(host)
}
let delegate = SrpPairingSessionDelegate()
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = requestTimeout
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
var isOnline = false
var serverReportedSha: String?
var detailsMsg = ""
do {
let (data, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse, http.statusCode == 200 {
isOnline = true
if let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let sha = json["certSha256"] as? String {
serverReportedSha = sha.lowercased()
}
detailsMsg = "Portal TV service online (HTTP 200)"
} else {
isOnline = true
detailsMsg = "Portal TV reached (HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0))"
}
} catch {
detailsMsg = "Connection failed: \(error.localizedDescription)"
}
let leafCertSha = delegate.capturedCertSha256Hex?.lowercased()
let pinnedCertSha = credentialStorage.getPinnedCertSha256()?.lowercased()
let authToken = credentialStorage.getAuthToken()
let isPaired = (authToken != nil && pinnedCertSha != nil)
let isCertPinMatching: Bool
if let pinned = pinnedCertSha, let leaf = leafCertSha {
isCertPinMatching = (pinned.caseInsensitiveCompare(leaf) == .orderedSame)
} else {
isCertPinMatching = false
}
return PortalStatus(
host: host,
isOnline: isOnline,
isPaired: isPaired,
pinnedCertSha256: pinnedCertSha,
leafCertSha256: leafCertSha,
isCertPinMatching: isCertPinMatching,
serverReportedCertSha256: serverReportedSha,
details: detailsMsg
)
}
// MARK: - Pairing Phase
/// Step 1: Initiate pairing over ephemeral TLS, capturing the certificate digest and SRP parameters.
public func initiatePairing() async throws -> PairingSession {
guard let url = URL(string: "\(baseURLString)/auth/srp/init") else {
throw PortalClientError.invalidHost(host)
}
let delegate = SrpPairingSessionDelegate()
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = requestTimeout
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Accept")
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw PortalClientError.communicationError("Failed to initiate pairing: \(error.localizedDescription)")
}
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
let msg = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)"
throw PortalClientError.pairingFailed("Init rejected by server: \(msg)")
}
guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let pairingId = json["pairingId"] as? String,
let salt = json["salt"] as? String,
let b = json["B"] as? String else {
throw PortalClientError.jsonParsingError("Invalid JSON payload from /auth/srp/init")
}
guard let certData = delegate.capturedCertSha256,
let certHex = delegate.capturedCertSha256Hex else {
throw PortalClientError.pairingFailed("Failed to capture TLS certificate for channel binding")
}
let srpClient = PortalSrpClient()
let expiresIn = json["expiresIn"] as? Int
return PairingSession(
pairingId: pairingId,
saltHex: salt,
pubBHex: b,
expiresIn: expiresIn,
capturedCertSha256: certData,
capturedCertSha256Hex: certHex,
srpClient: srpClient
)
}
/// Step 2: Complete pairing with user PIN, verifying server M2 and pinning the certificate.
public func completePairing(session: PairingSession, pin: String) async throws -> PairingResult {
let cleanPin = pin.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanPin.isEmpty else {
throw PortalClientError.pairingFailed("PIN cannot be empty")
}
let m1Hex: String
do {
m1Hex = try session.srpClient.computeM1(
saltHex: session.saltHex,
pubBHex: session.pubBHex,
pin: cleanPin,
tlsCertSha256: session.capturedCertSha256
)
} catch {
throw PortalClientError.pairingFailed("SRP computation failed: \(error.localizedDescription)")
}
guard let url = URL(string: "\(baseURLString)/auth/srp/verify") else {
throw PortalClientError.invalidHost(host)
}
let delegate = SrpPairingSessionDelegate()
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = requestTimeout
let urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let payload: [String: String] = [
"pairingId": session.pairingId,
"A": session.srpClient.pubAHex,
"M1": m1Hex
]
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
let data: Data
let response: URLResponse
do {
(data, response) = try await urlSession.data(for: request)
} catch {
throw PortalClientError.communicationError("Verification request failed: \(error.localizedDescription)")
}
let http = response as? HTTPURLResponse
guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
let bodyStr = String(data: data, encoding: .utf8) ?? ""
throw PortalClientError.pairingFailed("Invalid response from server: \(bodyStr)")
}
if http?.statusCode == 200,
let m2Hex = json["M2"] as? String,
let token = json["token"] as? String {
// Verify server M2
do {
try session.srpClient.verifyServerM2(serverM2Hex: m2Hex)
} catch {
throw PortalClientError.pairingFailed("Server proof M2 verification failed! Potential MITM attack: \(error.localizedDescription)")
}
// Save credentials
credentialStorage.save(token: token, certSha256: session.capturedCertSha256Hex)
return PairingResult(
success: true,
token: token,
pinnedCertSha256Hex: session.capturedCertSha256Hex,
serverM2Hex: m2Hex
)
} else {
let errorMsg = json["message"] as? String ?? json["error"] as? String ?? "Pairing rejected"
let attempts = json["attemptsLeft"] as? Int
var fullMsg = errorMsg
if let a = attempts {
fullMsg += " (\(a) attempts remaining)"
}
throw PortalClientError.pairingFailed(fullMsg)
}
}
/// Full pairing flow: initiates pairing, computes M1 with PIN, verifies M2, and saves credentials.
public func pair(pin: String) async throws -> PairingResult {
let session = try await initiatePairing()
return try await completePairing(session: session, pin: pin)
}
// MARK: - Control Commands (Pinned TLS & Bearer Auth)
/// One-shot camera state (`GET /control/state`). Prefer [cameraStateEvents] for live UI.
public func cameraState() async throws -> PortalCameraState {
try await controlState(path: "/control/state")
}
/// Live camera state via SSE (`GET /control/events`). Emits initial state, then updates.
public func cameraStateEvents() -> AsyncThrowingStream<PortalCameraState, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
try await self.consumeCameraStateEvents(continuation: continuation)
} catch is CancellationError {
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
/// Switch mode (`DefaultAuto`, `Desk`, `Meeting`, `Fixed`). State arrives via SSE.
public func setMode(_ mode: String) async throws {
let encoded = mode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? mode
try await controlAck(path: "/control/mode?mode=\(encoded)")
}
/// Apply Fixed crop. State arrives via SSE.
public func setFixedCrop(x: Double, y: Double, scale: Double) async throws {
try await controlAck(path: "/control/fixed?x=\(x)&y=\(y)&scale=\(scale)")
}
/// Apply Desk framing tightness. State arrives via SSE.
public func setDeskTightness(_ tightness: Double) async throws {
try await controlAck(path: "/control/desk?tightness=\(tightness)")
}
/// Sends a control command over pinned HTTPS using stored credentials.
/// Prefer typed helpers (`cameraState`, `setMode`, ).
@discardableResult
public func control(command: String) async throws -> String {
let data = try await controlRequest(path: Self.formatControlPath(command))
return String(data: data, encoding: .utf8) ?? ""
}
private func controlAck(path: String) async throws {
let data = try await controlRequest(path: path)
if let state = try? JSONDecoder().decode(PortalCameraState.self, from: data) {
// Older servers still return state; ignore body shape either way.
_ = state
return
}
// Expect {"ok":true} or tolerate empty/other 200 bodies.
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let err = obj["error"] as? String {
let msg = obj["message"] as? String ?? err
throw PortalClientError.requestFailed(statusCode: 200, message: msg)
}
}
private func controlState(path: String) async throws -> PortalCameraState {
let data = try await controlRequest(path: path)
do {
return try JSONDecoder().decode(PortalCameraState.self, from: data)
} catch {
let body = String(data: data, encoding: .utf8) ?? ""
throw PortalClientError.jsonParsingError("Expected PortalCameraState, got: \(body)")
}
}
private func consumeCameraStateEvents(
continuation: AsyncThrowingStream<PortalCameraState, Error>.Continuation
) async throws {
guard let pinnedCert = credentialStorage.getPinnedCertSha256(),
let token = credentialStorage.getAuthToken() else {
throw PortalClientError.notPaired
}
guard let url = URL(string: "\(baseURLString)/control/events") else {
throw PortalClientError.invalidURL("\(baseURLString)/control/events")
}
// Same pinning + dataTask path as video/control `bytes(for:)` can miss
// session-level auth challenges and fall through to default (self-signed) trust.
let stream = PortalSSEDataStream(url: url, token: token, pinnedFingerprint: pinnedCert)
try await withTaskCancellationHandler {
try await stream.run { state in
continuation.yield(state)
}
continuation.finish()
} onCancel: {
stream.cancel()
}
}
private func controlRequest(path: String) async throws -> Data {
guard let pinnedCert = credentialStorage.getPinnedCertSha256(),
let token = credentialStorage.getAuthToken() else {
throw PortalClientError.notPaired
}
guard let url = URL(string: "\(baseURLString)\(path)") else {
throw PortalClientError.invalidURL("\(baseURLString)\(path)")
}
let delegate = PortalPinnedSessionDelegate(pinnedFingerprint: pinnedCert)
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = requestTimeout
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
if let mismatch = delegate.consumePinMismatch() {
throw PortalClientError.tlsPinningMismatch(expected: mismatch.expected, got: mismatch.got)
}
throw PortalClientError.communicationError(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw PortalClientError.communicationError("Invalid HTTP response")
}
if http.statusCode == 200 {
return data
}
let bodyString = String(data: data, encoding: .utf8) ?? ""
throw PortalClientError.requestFailed(statusCode: http.statusCode, message: bodyString)
}
static func formatControlPath(_ command: String) -> String {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("/control/") || trimmed == "/control" || trimmed.hasPrefix("/control?") {
return trimmed
} else if trimmed.hasPrefix("/control") {
return trimmed
} else if trimmed.hasPrefix("control/") {
return "/\(trimmed)"
} else if trimmed == "state" || trimmed.hasPrefix("state?") {
return "/control/\(trimmed)"
} else if trimmed.hasPrefix("/") {
return "/control\(trimmed)"
} else if trimmed.hasPrefix("mode ") {
let modeVal = String(trimmed.dropFirst("mode ".count)).trimmingCharacters(in: .whitespaces)
return "/control/mode?mode=\(modeVal)"
} else if trimmed.hasPrefix("mode?") || trimmed.hasPrefix("fixed?") || trimmed.hasPrefix("desk?") {
return "/control/\(trimmed)"
} else {
return "/control/\(trimmed)"
}
}
// MARK: - MITM Defense Test
/// Tests channel binding defense by computing M1 with an altered TLS certificate hash
/// and confirming that Portal TV rejects the pairing exchange.
public func testMitm(
simulatedMitmCertSha256: Data? = nil,
pin: String = "123456"
) async throws -> MitmDefenseResult {
let session = try await initiatePairing()
let fakeCertHash: Data
if let customFake = simulatedMitmCertSha256 {
fakeCertHash = customFake
} else {
// Alter genuine cert hash by bit inversion
fakeCertHash = Data(session.capturedCertSha256.map { ~$0 })
}
let fakeCertHex = SrpFormat.bytesToHex(fakeCertHash)
// Compute M1 bound to the fake cert
let m1Hex = try session.srpClient.computeM1(
saltHex: session.saltHex,
pubBHex: session.pubBHex,
pin: pin,
tlsCertSha256: fakeCertHash
)
guard let url = URL(string: "\(baseURLString)/auth/srp/verify") else {
throw PortalClientError.invalidHost(host)
}
let delegate = SrpPairingSessionDelegate()
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = requestTimeout
let urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: String] = [
"pairingId": session.pairingId,
"A": session.srpClient.pubAHex,
"M1": m1Hex
]
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (data, response) = try await urlSession.data(for: request)
let http = response as? HTTPURLResponse
let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
let isRejected = (http?.statusCode != 200)
let errMsg = json?["message"] as? String ?? json?["error"] as? String ?? "HTTP \(http?.statusCode ?? 0)"
let attemptsLeft = json?["attemptsLeft"] as? Int
return MitmDefenseResult(
attackDescription: "Simulated rogue MITM TLS certificate substitution",
simulatedMitmCertSha256Hex: fakeCertHex,
realCertSha256Hex: session.capturedCertSha256Hex,
defenseSuccessful: isRejected,
serverRejectionMessage: errMsg,
attemptsRemaining: attemptsLeft
)
}
}
@@ -0,0 +1,14 @@
//
// PortalEndpoints.swift
// PortalKit
//
import Foundation
public enum PortalEndpoints: Sendable {
/// Fixed PortalCam HTTPS / DNS-SD port ("TV" joke 5654).
public static let port: Int = 5654
/// Bonjour / DNS-SD service type (no trailing dot for NWBrowser).
public static let bonjourType = "_portalcam._tcp"
}
@@ -0,0 +1,220 @@
//
// PortalSSEDataStream.swift
// PortalKit
//
// Long-lived SSE reader using URLSessionDataDelegate + pin (same path as video).
//
import Foundation
/// Streams `text/event-stream` over pinned HTTPS via a classic data task.
final class PortalSSEDataStream: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate, @unchecked Sendable {
private let url: URL
private let token: String
private let pinnedFingerprint: String
private var session: URLSession?
private var task: URLSessionDataTask?
private var buffer = Data()
private var pendingDataLines: [String] = []
private var lastPinMismatch: (expected: String, got: String)?
private var onState: ((PortalCameraState) -> Void)?
private var finish: ((Result<Void, Error>) -> Void)?
private var finished = false
private let lock = NSLock()
init(url: URL, token: String, pinnedFingerprint: String) {
self.url = url
self.token = token
self.pinnedFingerprint = pinnedFingerprint
super.init()
}
func run(onState: @escaping (PortalCameraState) -> Void) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
self.lock.lock()
self.onState = onState
self.finish = { result in
cont.resume(with: result)
}
self.lock.unlock()
self.start()
}
}
func cancel() {
finishOnce(.failure(CancellationError()))
tearDown()
}
private func start() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = .infinity
config.timeoutIntervalForResource = .infinity
config.requestCachePolicy = .reloadIgnoringLocalCacheData
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
self.session = session
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
let task = session.dataTask(with: request)
self.task = task
task.resume()
}
private func tearDown() {
task?.cancel()
task = nil
session?.invalidateAndCancel()
session = nil
}
private func finishOnce(_ result: Result<Void, Error>) {
lock.lock()
guard !finished else {
lock.unlock()
return
}
finished = true
let done = finish
finish = nil
onState = nil
lock.unlock()
tearDown()
done?(result)
}
private func evaluateChallenge(
_ challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
PortalTlsPinning.evaluate(
challenge: challenge,
pinnedFingerprint: pinnedFingerprint
) { [weak self] disposition, credential in
if disposition == .cancelAuthenticationChallenge,
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust,
let cert = PortalTlsPinning.extractLeafCert(from: trust) {
let (_, got) = PortalTlsPinning.computeCertSha256(cert: cert)
if self?.pinnedFingerprint.caseInsensitiveCompare(got) != .orderedSame {
self?.lock.lock()
self?.lastPinMismatch = (expected: self?.pinnedFingerprint ?? "", got: got)
self?.lock.unlock()
}
}
completionHandler(disposition, credential)
}
}
// MARK: - URLSessionDelegate
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 let http = response as? HTTPURLResponse else {
completionHandler(.cancel)
finishOnce(.failure(PortalClientError.communicationError("Invalid HTTP response")))
return
}
guard http.statusCode == 200 else {
completionHandler(.cancel)
finishOnce(.failure(PortalClientError.requestFailed(
statusCode: http.statusCode,
message: "SSE connect failed"
)))
return
}
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
buffer.append(data)
consume()
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error {
let ns = error as NSError
lock.lock()
let mismatch = lastPinMismatch
lastPinMismatch = nil
lock.unlock()
if let mismatch {
finishOnce(.failure(PortalClientError.tlsPinningMismatch(
expected: mismatch.expected,
got: mismatch.got
)))
return
}
if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled {
finishOnce(.failure(CancellationError()))
return
}
finishOnce(.failure(PortalClientError.communicationError(error.localizedDescription)))
return
}
finishOnce(.success(()))
}
private func consume() {
while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) {
var lineData = buffer.subdata(in: buffer.startIndex..<newline)
buffer.removeSubrange(buffer.startIndex...newline)
if lineData.last == UInt8(ascii: "\r") {
lineData.removeLast()
}
let line = String(data: lineData, encoding: .utf8) ?? ""
handleSSELine(line)
}
}
private func handleSSELine(_ line: String) {
if line.hasPrefix(":") {
return
}
if line.hasPrefix("data:") {
let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
pendingDataLines.append(String(payload))
return
}
if line.isEmpty {
guard !pendingDataLines.isEmpty else { return }
let json = pendingDataLines.joined(separator: "\n")
pendingDataLines.removeAll(keepingCapacity: true)
guard let raw = json.data(using: .utf8),
let state = try? JSONDecoder().decode(PortalCameraState.self, from: raw) else {
return
}
lock.lock()
let emit = onState
lock.unlock()
emit?(state)
}
}
}
@@ -0,0 +1,156 @@
//
// PortalSrpClient.swift
// PortalKit
//
// SRP-6a (RFC 5054 2048-bit) client with cryptographic TLS channel binding.
//
import Foundation
import CryptoKit
import Security
public enum SrpError: LocalizedError, Sendable {
case invalidParameter(String)
case verificationFailed(String)
case serverRejected(String)
case tlsCertificateMissing
public var errorDescription: String? {
switch self {
case .invalidParameter(let m): return "SRP Parameter Error: \(m)"
case .verificationFailed(let m): return "SRP Verification Failed: \(m)"
case .serverRejected(let m): return "Portal Error: \(m)"
case .tlsCertificateMissing: return "TLS server certificate could not be retrieved"
}
}
}
public final class PortalSrpClient: @unchecked Sendable {
public static let group = SrpGroup.rfc5054_2048
public static let N = group.N
public static let g = group.g
public static let k = group.k
private let a: BigUInt
public let A: BigUInt
public private(set) var K: Data?
public private(set) var M1: Data?
public private(set) var tlsCertHash: Data?
/// Initializes client with either a randomly generated ephemeral private key `a`
/// or a predetermined `a` (primarily for test vectors and deterministic testing).
public init(a: BigUInt? = nil) {
if let customA = a {
self.a = customA
self.A = Self.g.power(customA, modulus: Self.N)
} else {
var aBytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, 32, &aBytes)
let randVal = BigUInt(Data(aBytes))
let privA = (randVal % (Self.N - 2)) + 1
self.a = privA
self.A = Self.g.power(privA, modulus: Self.N)
}
}
public var pubAHex: String {
Self.bytesToHex(A.toPadded256Data())
}
public var sessionKey: Data? {
K
}
public var clientM1: Data? {
M1
}
/// Compute M1 using server parameters, user PIN, and captured TLS certificate SHA-256 hash.
@discardableResult
public func computeM1(saltHex: String, pubBHex: String, pin: String, tlsCertSha256: Data) throws -> String {
guard let salt = Self.hexToBytes(saltHex), !salt.isEmpty else {
throw SrpError.invalidParameter("Invalid salt hex")
}
guard let bBytes = Self.hexToBytes(pubBHex), !bBytes.isEmpty else {
throw SrpError.invalidParameter("Invalid B hex")
}
guard !tlsCertSha256.isEmpty else {
throw SrpError.invalidParameter("TLS certificate hash cannot be empty")
}
let B = BigUInt(bBytes)
// Safety check B % N != 0
guard B % Self.N != 0 else {
throw SrpError.invalidParameter("Server public value B % N == 0")
}
// u = SHA256(pad256(A) || pad256(B))
let uHash = Self.sha256(A.toPadded256Data(), B.toPadded256Data())
let u = BigUInt(uHash)
guard u != 0 else {
throw SrpError.invalidParameter("Computed u == 0")
}
// x = SHA256(salt || UTF8(pin))
guard let pinData = pin.data(using: .utf8), !pinData.isEmpty else {
throw SrpError.invalidParameter("Invalid or empty PIN")
}
let xHash = Self.sha256(salt, pinData)
let x = BigUInt(xHash)
// S = (B - k * (g^x mod N) mod N) ^ (a + u * x) mod N
let S = SrpMath.computeClientS(
B: B,
k: Self.k,
g: Self.g,
x: x,
a: a,
u: u,
N: Self.N
)
// K = SHA256(pad256(S))
let sessionK = Self.sha256(S.toPadded256Data())
self.K = sessionK
self.tlsCertHash = tlsCertSha256
// M1 = SHA256(pad256(A) || pad256(B) || K || salt || tlsCertSha256)
let clientM1 = Self.sha256(A.toPadded256Data(), B.toPadded256Data(), sessionK, salt, tlsCertSha256)
self.M1 = clientM1
return Self.bytesToHex(clientM1)
}
/// Verify M2 returned by the server.
public func verifyServerM2(serverM2Hex: String) throws {
guard let expectedM1 = M1, let sessionK = K, let certHash = tlsCertHash else {
throw SrpError.verificationFailed("Client state not initialized for verification")
}
guard let serverM2 = Self.hexToBytes(serverM2Hex) else {
throw SrpError.verificationFailed("Invalid server M2 hex")
}
// Expected M2 = SHA256(pad256(A) || M1 || K || tlsCertSha256)
let expectedM2 = Self.sha256(A.toPadded256Data(), expectedM1, sessionK, certHash)
// Constant-time comparison
guard SrpFormat.constantTimeEquals(expectedM2, serverM2) else {
throw SrpError.verificationFailed("Server evidence M2 does not match (potential MITM or incorrect credentials)")
}
}
// MARK: - Helpers
public static func sha256(_ parts: Data...) -> Data {
SrpGroup.rfc5054_2048.hashAlgorithm.hash(parts)
}
public static func bytesToHex(_ data: Data) -> String {
SrpFormat.bytesToHex(data)
}
public static func hexToBytes(_ hex: String) -> Data? {
SrpFormat.hexToBytes(hex)
}
}
@@ -0,0 +1,191 @@
//
// SrpGroup.swift
// PortalKit
//
// SRP-6a RFC 5054 prime groups and cryptographic math helpers.
//
import Foundation
import CryptoKit
public enum SrpHashAlgorithm: Sendable {
case sha1
case sha256
public func hash(_ parts: Data...) -> Data {
hash(parts)
}
public func hash(_ parts: [Data]) -> Data {
switch self {
case .sha1:
var hasher = Insecure.SHA1()
for p in parts { hasher.update(data: p) }
return Data(hasher.finalize())
case .sha256:
var hasher = SHA256()
for p in parts { hasher.update(data: p) }
return Data(hasher.finalize())
}
}
}
public struct SrpGroup: Sendable {
public let N: BigUInt
public let g: BigUInt
public let byteLength: Int
public let k: BigUInt
public let hashAlgorithm: SrpHashAlgorithm
public init(NHex: String, g: BigUInt, byteLength: Int, hashAlgorithm: SrpHashAlgorithm) {
let cleanHex = NHex.replacingOccurrences(of: "\\s+", with: "", options: .regularExpression)
guard let nVal = BigUInt(cleanHex, radix: 16) else {
fatalError("Invalid N hex for SrpGroup")
}
self.N = nVal
self.g = g
self.byteLength = byteLength
self.hashAlgorithm = hashAlgorithm
let nData = nVal.toPaddedData(byteCount: byteLength)
let gData = g.toPaddedData(byteCount: byteLength)
let kHash = hashAlgorithm.hash(nData, gData)
self.k = BigUInt(kHash)
}
/// RFC 5054 Appendix A 1024-bit group with SHA-1 (for test vector verification)
public static let rfc5054_1024 = SrpGroup(
NHex: """
EEAF0AB9ADB38DD69C33F80AFA8FC5E86072618775FF3C0B9EA2314C9C256576\
D674DF7496EA81D3383B4813D692C6E0E0D5D8E250B98BE48E495C1D6089DAD1\
5DC7D7B46154D6B6CE8EF4AD69B15D4982559B297BCF1885C529F566660E57EC\
68EDBC3C05726CC02FD4CBF4976EAA9AFD5138FE8376435B9FC61D2FC0EB06E3
""",
g: BigUInt(2),
byteLength: 128,
hashAlgorithm: .sha1
)
/// RFC 5054 2048-bit group with SHA-256 (standard Portal TV group)
public static let rfc5054_2048 = SrpGroup(
NHex: """
FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74\
020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437\
4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED\
EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05\
98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB\
9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B\
E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718\
3995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF
""",
g: BigUInt(2),
byteLength: 256,
hashAlgorithm: .sha256
)
}
public enum SrpMath {
/// Calculate x according to RFC 5054: x = H(s | H(I | ":" | P))
public static func computeRfc5054X(identity: String, password: String, salt: Data, hashAlgorithm: SrpHashAlgorithm) -> BigUInt {
let colonData = ":".data(using: .utf8)!
let identityData = identity.data(using: .utf8)!
let passwordData = password.data(using: .utf8)!
let innerHash = hashAlgorithm.hash(identityData, colonData, passwordData)
let outerHash = hashAlgorithm.hash(salt, innerHash)
return BigUInt(outerHash)
}
/// Calculate x according to Portal TV protocol: x = SHA256(salt | UTF8(pin))
public static func computePortalX(pin: String, salt: Data, hashAlgorithm: SrpHashAlgorithm = .sha256) -> BigUInt {
let pinData = pin.data(using: .utf8)!
let hash = hashAlgorithm.hash(salt, pinData)
return BigUInt(hash)
}
/// Verifier v = g^x mod N
public static func computeVerifier(g: BigUInt, x: BigUInt, N: BigUInt) -> BigUInt {
g.power(x, modulus: N)
}
/// Public A = g^a mod N
public static func computeA(g: BigUInt, a: BigUInt, N: BigUInt) -> BigUInt {
g.power(a, modulus: N)
}
/// Public B = (k*v + g^b) mod N
public static func computeB(k: BigUInt, v: BigUInt, g: BigUInt, b: BigUInt, N: BigUInt) -> BigUInt {
let kv = (k * v) % N
let gb = g.power(b, modulus: N)
return (kv + gb) % N
}
/// Scrambler u = H(PAD(A) | PAD(B))
public static func computeU(A: BigUInt, B: BigUInt, padLength: Int, hashAlgorithm: SrpHashAlgorithm) -> BigUInt {
let aData = A.toPaddedData(byteCount: padLength)
let bData = B.toPaddedData(byteCount: padLength)
let hash = hashAlgorithm.hash(aData, bData)
return BigUInt(hash)
}
/// Client premaster secret S = (B - k * (g^x mod N)) ^ (a + u * x) mod N
public static func computeClientS(B: BigUInt, k: BigUInt, g: BigUInt, x: BigUInt, a: BigUInt, u: BigUInt, N: BigUInt) -> BigUInt {
let gx = g.power(x, modulus: N)
let kgx = (k * gx) % N
let base = (B >= kgx) ? (B - kgx) : (N - ((kgx - B) % N))
let exp = a + u * x
return base.power(exp, modulus: N)
}
/// Server premaster secret S = (A * (v^u mod N)) ^ b mod N
public static func computeServerS(A: BigUInt, v: BigUInt, u: BigUInt, b: BigUInt, N: BigUInt) -> BigUInt {
let vu = v.power(u, modulus: N)
let base = (A * vu) % N
return base.power(b, modulus: N)
}
}
public enum SrpFormat {
public static func bytesToHex(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
public static func hexToBytes(_ hex: String) -> Data? {
let clean = hex.components(separatedBy: .whitespacesAndNewlines).joined()
guard clean.count % 2 == 0 else { return nil }
var data = Data(capacity: clean.count / 2)
var index = clean.startIndex
while index < clean.endIndex {
let nextIndex = clean.index(index, offsetBy: 2)
let byteStr = String(clean[index..<nextIndex])
guard let byte = UInt8(byteStr, radix: 16) else { return nil }
data.append(byte)
index = nextIndex
}
return data
}
public static func constantTimeEquals(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count else { return false }
var result: UInt8 = 0
for i in 0..<a.count {
result |= (a[i] ^ b[i])
}
return result == 0
}
}
extension BigUInt {
public func toPaddedData(byteCount: Int) -> Data {
let raw = self.serialize()
if raw.count >= byteCount {
return raw.suffix(byteCount)
}
var padded = Data(repeating: 0, count: byteCount - raw.count)
padded.append(raw)
return padded
}
public func toPadded256Data() -> Data {
toPaddedData(byteCount: 256)
}
}
@@ -0,0 +1,128 @@
//
// SrpServerMock.swift
// PortalKit
//
// Mock Portal TV SRP-6a server (matching PortalSrp.kt) for testing and simulations.
//
import Foundation
import CryptoKit
public final class SrpServerMock: @unchecked Sendable {
public let pairingId: String
public let pin: String
public let salt: Data
public let v: BigUInt
public let b: BigUInt
public let B: BigUInt
public let group: SrpGroup
public var saltHex: String {
SrpFormat.bytesToHex(salt)
}
public var pubBHex: String {
SrpFormat.bytesToHex(B.toPadded256Data())
}
public init(
pin: String = "123456",
salt: Data? = nil,
b: BigUInt? = nil,
pairingId: String = UUID().uuidString,
group: SrpGroup = .rfc5054_2048
) {
self.pairingId = pairingId
self.pin = pin
self.group = group
let effectiveSalt = salt ?? {
var bytes = [UInt8](repeating: 0, count: 16)
_ = SecRandomCopyBytes(kSecRandomDefault, 16, &bytes)
return Data(bytes)
}()
self.salt = effectiveSalt
// x = SHA256(salt || UTF8(pin))
let pinData = pin.data(using: .utf8)!
let xHash = group.hashAlgorithm.hash(effectiveSalt, pinData)
let x = BigUInt(xHash)
// v = g^x mod N
self.v = group.g.power(x, modulus: group.N)
// b = private server exponent
if let customB = b {
self.b = customB
} else {
var bBytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, 32, &bBytes)
let randVal = BigUInt(Data(bBytes))
self.b = (randVal % (group.N - 2)) + 1
}
// B = (k*v + g^b) mod N
self.B = SrpMath.computeB(k: group.k, v: self.v, g: group.g, b: self.b, N: group.N)
}
public struct VerifyResult: Sendable {
public let success: Bool
public let M2Hex: String?
public let token: String?
public let errorMessage: String?
}
/// Verifies client credentials against the server's state and server certificate hash.
public func verifyClient(pubAHex: String, clientM1Hex: String, serverTlsCertSha256: Data) -> VerifyResult {
guard let aBytes = SrpFormat.hexToBytes(pubAHex), !aBytes.isEmpty else {
return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Invalid A hex")
}
guard let clientM1 = SrpFormat.hexToBytes(clientM1Hex), !clientM1.isEmpty else {
return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Invalid M1 hex")
}
let A = BigUInt(aBytes)
guard A % group.N != 0 else {
return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Invalid public key A: A % N == 0")
}
let padLen = group.byteLength
let aPadded = A.toPaddedData(byteCount: padLen)
let bPadded = B.toPaddedData(byteCount: padLen)
// u = H(PAD(A) || PAD(B))
let uHash = group.hashAlgorithm.hash(aPadded, bPadded)
let u = BigUInt(uHash)
guard u != 0 else {
return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Computed u == 0")
}
// Server computes S = (A * (v^u mod N)) ^ b mod N
let S = SrpMath.computeServerS(A: A, v: v, u: u, b: b, N: group.N)
let sPadded = S.toPaddedData(byteCount: padLen)
// K = H(PAD(S))
let K = group.hashAlgorithm.hash(sPadded)
// Expected M1 = H(PAD(A) || PAD(B) || K || salt || serverTlsCertSha256)
let expectedM1 = group.hashAlgorithm.hash(aPadded, bPadded, K, salt, serverTlsCertSha256)
guard SrpFormat.constantTimeEquals(clientM1, expectedM1) else {
return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "M1 verification failed: wrong PIN or TLS MITM detected")
}
// M2 = H(PAD(A) || M1 || K || serverTlsCertSha256)
let M2 = group.hashAlgorithm.hash(aPadded, clientM1, K, serverTlsCertSha256)
let m2Hex = SrpFormat.bytesToHex(M2)
// Generate token
var tokenBytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, 32, &tokenBytes)
let token = Data(tokenBytes).base64EncodedString()
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "=", with: "")
return VerifyResult(success: true, M2Hex: m2Hex, token: token, errorMessage: nil)
}
}
@@ -0,0 +1,216 @@
//
// PortalTlsPinning.swift
// PortalKit
//
// TLS Certificate Pinning, Challenge Evaluation, and Leaf Certificate Extraction.
//
import Foundation
import Security
import CryptoKit
import os.log
public enum PortalTlsPinning {
private static let log = Logger(subsystem: "com.kovtash.portalkit", category: "tls")
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 = SrpFormat.bytesToHex(data)
return (data, hex)
}
public static func evaluate(
challenge: URLAuthenticationChallenge,
pinnedFingerprint: String? = PortalAuth.pinnedCertSha256,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
// Avoid performDefaultHandling it surfaces "invalid certificate" UI for self-signed servers.
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
guard let cert = extractLeafCert(from: serverTrust) else {
log.error("PortalKit TLS: No certificate found in server trust chain")
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let (_, certHashHex) = computeCertSha256(cert: cert)
guard let pinned = pinnedFingerprint, !pinned.isEmpty else {
log.error("PortalKit TLS: No pinned certificate configured; rejecting connection")
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
guard certHashHex.caseInsensitiveCompare(pinned) == .orderedSame else {
log.error("PortalKit TLS Pinning Mismatch! Expected: \(pinned, privacy: .public), Got: \(certHashHex, privacy: .public)")
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Pin match is sufficient; SecTrustEvaluateWithError rejects our self-signed Portal cert.
completionHandler(.useCredential, URLCredential(trust: serverTrust))
}
/// Connects to a host and captures the presented TLS leaf certificate fingerprint.
public static func fetchServerCertFingerprint(
url: URL,
timeout: TimeInterval = 10
) async throws -> (data: Data, hex: String) {
let delegate = SrpPairingSessionDelegate()
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = timeout
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = timeout
// Perform request to trigger TLS handshake
_ = try? await session.data(for: request)
guard let certData = delegate.capturedCertSha256,
let certHex = delegate.capturedCertSha256Hex else {
throw SrpError.tlsCertificateMissing
}
return (certData, certHex)
}
}
/// URLSessionDelegate for the pairing phase: accepts the self-signed certificate,
/// but records its DER and SHA-256 digest so it can be channel-bound into SRP-6a.
public final class SrpPairingSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
private let lock = NSLock()
private var _capturedCertDer: Data?
private var _capturedCertSha256: Data?
private var _capturedCertSha256Hex: String?
public override init() {
super.init()
}
public var capturedCertDer: Data? {
lock.lock()
defer { lock.unlock() }
return _capturedCertDer
}
public var capturedCertSha256: Data? {
lock.lock()
defer { lock.unlock() }
return _capturedCertSha256
}
public var capturedCertSha256Hex: String? {
lock.lock()
defer { lock.unlock() }
return _capturedCertSha256Hex
}
public func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust,
let cert = PortalTlsPinning.extractLeafCert(from: serverTrust) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let (data, hex) = PortalTlsPinning.computeCertSha256(cert: cert)
let der = SecCertificateCopyData(cert) as Data
lock.lock()
self._capturedCertDer = der
self._capturedCertSha256 = data
self._capturedCertSha256Hex = hex
lock.unlock()
// Accept trust for the pairing exchange. SRP-6a cryptographic channel binding
// guarantees that if an attacker intercepted this TLS connection with a rogue cert,
// the M1 verification on Portal will fail and pairing will be rejected.
completionHandler(.useCredential, URLCredential(trust: serverTrust))
}
}
/// URLSessionDelegate for standard requests (control, health, SSE) enforcing certificate pinning.
public final class PortalPinnedSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate, @unchecked Sendable {
public let pinnedFingerprint: String?
/// Set when the last challenge was rejected for a pin mismatch (for mapping NSURLErrorCancelled).
public private(set) var lastPinMismatch: (expected: String, got: String)?
private let lock = NSLock()
public init(pinnedFingerprint: String? = PortalAuth.pinnedCertSha256) {
self.pinnedFingerprint = pinnedFingerprint
super.init()
}
public func consumePinMismatch() -> (expected: String, got: String)? {
lock.lock()
defer { lock.unlock() }
let value = lastPinMismatch
lastPinMismatch = nil
return value
}
private func recordEvaluate(
challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
PortalTlsPinning.evaluate(
challenge: challenge,
pinnedFingerprint: pinnedFingerprint
) { [weak self] disposition, credential in
if disposition == .cancelAuthenticationChallenge,
let pinned = self?.pinnedFingerprint,
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust,
let cert = PortalTlsPinning.extractLeafCert(from: trust) {
let (_, got) = PortalTlsPinning.computeCertSha256(cert: cert)
if pinned.caseInsensitiveCompare(got) != .orderedSame {
self?.lock.lock()
self?.lastPinMismatch = (expected: pinned, got: got)
self?.lock.unlock()
}
}
completionHandler(disposition, credential)
}
}
public func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
recordEvaluate(challenge: challenge, completionHandler: completionHandler)
}
/// Async `bytes(for:)` / `data(for:)` deliver server-trust challenges here on recent macOS.
public func urlSession(
_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
recordEvaluate(challenge: challenge, completionHandler: completionHandler)
}
}
@@ -0,0 +1,5 @@
import Foundation
public struct PortalKitVersion {
public static let version = "1.0.0"
}
@@ -0,0 +1,272 @@
//
// main.swift
// portalkit-cli
//
// Command-line interface for Portal TV pairing, TLS pinning, control, and MITM defense verification.
//
import Foundation
import PortalKit
/// Hybrid credential storage that reads/writes both macOS Keychain and ~/.portalkit/credentials.json
final class CliCredentialStorage: CredentialStorage, @unchecked Sendable {
private let keychain = KeychainCredentialStorage(accessGroup: nil)
private let fileStorage = FileCredentialStorage()
func getAuthToken() -> String? {
if let token = keychain.getAuthToken(), !token.isEmpty {
return token
}
return fileStorage.getAuthToken()
}
func getPinnedCertSha256() -> String? {
if let cert = keychain.getPinnedCertSha256(), !cert.isEmpty {
return cert
}
return fileStorage.getPinnedCertSha256()
}
func save(token: String, certSha256: String) {
keychain.save(token: token, certSha256: certSha256)
fileStorage.save(token: token, certSha256: certSha256)
}
func clear() {
keychain.clear()
fileStorage.clear()
}
}
@main
struct PortalKitCli {
static let storage = CliCredentialStorage()
static func printUsage() {
let usage = """
PortalKit CLI - Portal TV pairing, TLS pinning, and control tool
USAGE:
portalkit-cli <command> [options]
COMMANDS:
pair <host> [--pin <pin>]
Initiates SRP-6a pairing over ephemeral TLS, prompts for (or uses) PIN,
verifies server M2 proof, and pins the TLS certificate.
status <host>
Checks Portal TV service status, inspects the presented TLS leaf certificate,
and verifies certificate pinning state.
control <host> <command>
Sends control commands over pinned HTTPS. Mutations return {"ok":true};
live state is on GET /control/events (SSE).
({"mode":"","config":{…}}); failures return {"error":"","message":""}.
Commands:
state
mode <DefaultAuto|Desk|Meeting|Fixed>
fixed?x=<0-1>&y=<0-1>&scale=<0.1-1>
desk?tightness=<0-1>
test-mitm <host>
Simulates a Man-in-the-Middle (MITM) attack with a substituted TLS certificate
to verify that SRP-6a channel binding prevents unauthorized interception.
OPTIONS:
--pin, -p <pin> 6-digit PIN displayed on Portal TV (for 'pair' command)
--help, -h Show this help reference
EXAMPLES:
portalkit-cli pair 10.0.0.10:5654 --pin 123456
portalkit-cli status 10.0.0.10:5654
portalkit-cli control 10.0.0.10:5654 state
portalkit-cli control 10.0.0.10:5654 mode Desk
portalkit-cli control 10.0.0.10:5654 fixed?x=0.5&y=0.5&scale=1.0
portalkit-cli control 10.0.0.10:5654 desk?tightness=0.5
portalkit-cli test-mitm 10.0.0.10:5654
"""
print(usage)
}
static func main() async {
var args = Array(CommandLine.arguments.dropFirst())
if args.isEmpty || args.contains("--help") || args.contains("-h") || args.first == "help" {
printUsage()
return
}
let command = args.removeFirst()
switch command {
case "pair":
await handlePair(args: args)
case "status":
await handleStatus(args: args)
case "control":
await handleControl(args: args)
case "test-mitm":
await handleTestMitm(args: args)
default:
print("Unknown command: '\(command)'. Run with --help for usage.")
exit(1)
}
}
// MARK: - Command Handlers
static func handlePair(args: [String]) async {
guard let host = args.first, !host.hasPrefix("-") else {
print("Error: Missing <host> parameter.\nUsage: portalkit-cli pair <host> [--pin <pin>]")
exit(1)
}
var pin: String? = nil
var idx = 1
while idx < args.count {
if (args[idx] == "--pin" || args[idx] == "-p"), idx + 1 < args.count {
pin = args[idx + 1]
idx += 2
} else {
idx += 1
}
}
let client = PortalClient(host: host, credentialStorage: storage)
do {
print("Initiating SRP-6a pairing with Portal TV at \(client.host)")
let session = try await client.initiatePairing()
print("Connected over ephemeral TLS.")
print("Captured Server Leaf Cert SHA-256: \(session.capturedCertSha256Hex)")
print("Pairing ID: \(session.pairingId)")
if let exp = session.expiresIn {
print("Session expires in: \(exp)s")
}
let effectivePin: String
if let provided = pin, !provided.isEmpty {
effectivePin = provided
} else {
print("Enter the 6-digit PIN displayed on Portal TV: ", terminator: "")
fflush(stdout)
effectivePin = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
guard !effectivePin.isEmpty else {
print("Error: No PIN entered.")
exit(1)
}
print("Computing channel-bound M1 proof…")
print("Submitting M1 and verifying Portal TV M2 proof…")
let result = try await client.completePairing(session: session, pin: effectivePin)
print("\nPairing successful!")
print("Server Proof M2 Verified: \(result.serverM2Hex.prefix(16))")
print("Bearer Token: \(result.token.prefix(12))")
print("TLS Certificate Pinned: \(result.pinnedCertSha256Hex)")
print("Credentials saved successfully.")
} catch {
print("\nPairing failed: \(error.localizedDescription)")
exit(1)
}
}
static func handleStatus(args: [String]) async {
guard let host = args.first, !host.hasPrefix("-") else {
print("Error: Missing <host> parameter.\nUsage: portalkit-cli status <host>")
exit(1)
}
let client = PortalClient(host: host, credentialStorage: storage)
print("Checking Portal TV status at \(client.host)")
do {
let status = try await client.status()
print("Host: \(status.host)")
print("Service Online: \(status.isOnline ? "YES" : "NO")")
print("Details: \(status.details)")
if let srvSha = status.serverReportedCertSha256 {
print("Server Cert Digest: \(srvSha)")
}
if let leafSha = status.leafCertSha256 {
print("Leaf Cert SHA-256: \(leafSha)")
} else {
print("Leaf Cert SHA-256: [Not reached / no TLS cert]")
}
if let pinned = status.pinnedCertSha256 {
print("Pinned Cert Digest: \(pinned)")
print("Pin Status: \(status.isCertPinMatching ? "VALID (Match)" : "MISMATCH / INVALID")")
} else {
print("Pinned Cert Digest: [None configured]")
}
print("Paired: \(status.isPaired ? "YES" : "NO")")
} catch {
print("Status check failed: \(error.localizedDescription)")
exit(1)
}
}
static func handleControl(args: [String]) async {
guard let host = args.first, !host.hasPrefix("-") else {
print("Error: Missing <host> parameter.\nUsage: portalkit-cli control <host> <command>")
exit(1)
}
let commandArgs = Array(args.dropFirst())
guard !commandArgs.isEmpty else {
print("Error: Missing <command> parameter.\nUsage: portalkit-cli control <host> <command>")
print("Examples:\n control <host> mode Desk\n control <host> fixed?x=0.5&y=0.5&scale=1.0")
exit(1)
}
let fullCommand = commandArgs.joined(separator: " ")
let client = PortalClient(host: host, credentialStorage: storage)
print("Sending control command '\(fullCommand)' to \(client.host) via pinned HTTPS…")
do {
let response = try await client.control(command: fullCommand)
print("Response: \(response)")
} catch {
print("Control command failed: \(error.localizedDescription)")
exit(1)
}
}
static func handleTestMitm(args: [String]) async {
guard let host = args.first, !host.hasPrefix("-") else {
print("Error: Missing <host> parameter.\nUsage: portalkit-cli test-mitm <host>")
exit(1)
}
let client = PortalClient(host: host, credentialStorage: storage)
print("Testing SRP-6a TLS Channel Binding Defense against Portal TV at \(client.host)")
do {
let result = try await client.testMitm()
print("\n================== CHANNEL BINDING DEFENSE TEST ==================")
print("Attack Simulation: \(result.attackDescription)")
print("Genuine Server Cert SHA-256: \(result.realCertSha256Hex)")
print("Simulated MITM Cert SHA-256: \(result.simulatedMitmCertSha256Hex)")
print("Server Response: \(result.serverRejectionMessage)")
if let remaining = result.attemptsRemaining {
print("Pairing Attempts Left: \(remaining)")
}
print("------------------------------------------------------------------")
if result.defenseSuccessful {
print("Verdict: PASSED - Server successfully rejected the altered certificate binding!")
print("Security Guarantee: An active MITM proxy cannot forge authentication without detection.")
} else {
print("Verdict: FAILED - Server unexpectedly accepted the connection!")
exit(1)
}
print("==================================================================")
} catch {
print("Test failed to execute: \(error.localizedDescription)")
exit(1)
}
}
}
@@ -0,0 +1,103 @@
//
// BigUIntPaddingTests.swift
// PortalKitTests
//
// Verification of BigUInt 256-byte padding, truncation, and hex conversion edge cases.
//
import XCTest
@testable import PortalKit
final class BigUIntPaddingTests: XCTestCase {
func testZeroPaddingTo256Bytes() {
let zero = BigUInt(0)
let padded = zero.toPadded256Data()
XCTAssertEqual(padded.count, 256, "Zero must produce exactly 256 bytes")
XCTAssertEqual(padded, Data(repeating: 0, count: 256))
}
func testSmallNumberPadding() {
let one = BigUInt(1)
let padded = one.toPadded256Data()
XCTAssertEqual(padded.count, 256)
XCTAssertEqual(padded.prefix(255), Data(repeating: 0, count: 255))
XCTAssertEqual(padded.last, 1)
let value = BigUInt(0x12345678)
let paddedValue = value.toPadded256Data()
XCTAssertEqual(paddedValue.count, 256)
XCTAssertEqual(paddedValue.suffix(4), Data([0x12, 0x34, 0x56, 0x78]))
}
func testExact256ByteValue() {
// Portal TV 2048-bit prime N is exactly 256 bytes
let N = PortalSrpClient.N
let paddedN = N.toPadded256Data()
XCTAssertEqual(paddedN.count, 256)
XCTAssertEqual(paddedN.first, 0xFF)
XCTAssertEqual(paddedN.last, 0xFF)
// Raw serialize count for 2048-bit N starting with 0xFF is 256 bytes
XCTAssertEqual(N.serialize().count, 256)
XCTAssertEqual(paddedN, N.serialize())
}
func testVariableLengthPadding() {
let val = BigUInt(0xAABB)
// Pad to 4 bytes: 00 00 AA BB
let p4 = val.toPaddedData(byteCount: 4)
XCTAssertEqual(p4, Data([0x00, 0x00, 0xAA, 0xBB]))
// Pad to 2 bytes: AA BB
let p2 = val.toPaddedData(byteCount: 2)
XCTAssertEqual(p2, Data([0xAA, 0xBB]))
// Truncate to 1 byte: BB (suffix)
let p1 = val.toPaddedData(byteCount: 1)
XCTAssertEqual(p1, Data([0xBB]))
}
func testHexConversionEdgeCases() {
// Empty data
XCTAssertEqual(SrpFormat.bytesToHex(Data()), "")
XCTAssertEqual(SrpFormat.hexToBytes(""), Data())
// Leading zero single byte
XCTAssertEqual(SrpFormat.bytesToHex(Data([0x05])), "05")
XCTAssertEqual(SrpFormat.hexToBytes("05"), Data([0x05]))
// High nybble / low nybble
XCTAssertEqual(SrpFormat.bytesToHex(Data([0xF0, 0x0F])), "f00f")
XCTAssertEqual(SrpFormat.hexToBytes("f00f"), Data([0xF0, 0x0F]))
XCTAssertEqual(SrpFormat.hexToBytes("F00F"), Data([0xF0, 0x0F])) // case insensitive
// Whitespace and newline tolerance
let spacedHex = " f0 0f \n 12\t 34 \r\n"
XCTAssertEqual(SrpFormat.hexToBytes(spacedHex), Data([0xF0, 0x0F, 0x12, 0x34]))
// Invalid: odd length
XCTAssertNil(SrpFormat.hexToBytes("123"))
XCTAssertNil(SrpFormat.hexToBytes("f"))
// Invalid: non-hex characters
XCTAssertNil(SrpFormat.hexToBytes("123g"))
XCTAssertNil(SrpFormat.hexToBytes("zz"))
XCTAssertNil(SrpFormat.hexToBytes("!!"))
}
func testConstantTimeEqualsEdgeCases() {
let a = Data([0x01, 0x02, 0x03, 0x04])
let b = Data([0x01, 0x02, 0x03, 0x04])
let diffStart = Data([0xFF, 0x02, 0x03, 0x04])
let diffMid = Data([0x01, 0xFF, 0x03, 0x04])
let diffEnd = Data([0x01, 0x02, 0x03, 0xFF])
let diffLength = Data([0x01, 0x02, 0x03])
XCTAssertTrue(SrpFormat.constantTimeEquals(a, b))
XCTAssertTrue(SrpFormat.constantTimeEquals(Data(), Data()))
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffStart))
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffMid))
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffEnd))
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffLength))
}
}
@@ -0,0 +1,117 @@
//
// ChannelBindingTests.swift
// PortalKitTests
//
// Verification of cryptographic TLS channel binding in SRP-6a M1 and M2.
//
import XCTest
@testable import PortalKit
final class ChannelBindingTests: XCTestCase {
let pin = "481516"
let genuineCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("GENUINE_PORTAL_CERT_DER".data(using: .utf8)!)
let rogueCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("ROGUE_PROXY_CERT_DER".data(using: .utf8)!)
func testMatchingCertHashSucceeds() throws {
let server = SrpServerMock(pin: pin)
let client = PortalSrpClient()
// Both use the same genuine TLS cert hash
let m1Hex = try client.computeM1(
saltHex: server.saltHex,
pubBHex: server.pubBHex,
pin: pin,
tlsCertSha256: genuineCertHash
)
let serverRes = server.verifyClient(
pubAHex: client.pubAHex,
clientM1Hex: m1Hex,
serverTlsCertSha256: genuineCertHash
)
XCTAssertTrue(serverRes.success)
XCTAssertNotNil(serverRes.M2Hex)
XCTAssertNoThrow(try client.verifyServerM2(serverM2Hex: serverRes.M2Hex!))
}
func testAlteredCertHashFailsServerM1Verification() throws {
let server = SrpServerMock(pin: pin)
let client = PortalSrpClient()
// Client is tricked into connecting through a MITM proxy presenting rogue cert
let m1Hex = try client.computeM1(
saltHex: server.saltHex,
pubBHex: server.pubBHex,
pin: pin,
tlsCertSha256: rogueCertHash // Client uses rogue proxy cert hash
)
// Portal TV verifies against its real local certificate hash
let serverRes = server.verifyClient(
pubAHex: client.pubAHex,
clientM1Hex: m1Hex,
serverTlsCertSha256: genuineCertHash // Server uses genuine cert hash
)
XCTAssertFalse(serverRes.success, "Server must reject client M1 when TLS cert hashes differ")
XCTAssertNil(serverRes.token)
XCTAssertNil(serverRes.M2Hex)
}
func testAlteredCertHashFailsClientM2Verification() throws {
let server = SrpServerMock(pin: pin)
let client = PortalSrpClient()
// Client computes M1 bound to genuine cert
_ = try client.computeM1(
saltHex: server.saltHex,
pubBHex: server.pubBHex,
pin: pin,
tlsCertSha256: genuineCertHash
)
// Attacker attempts to forge M2 or server computes M2 bound to a different cert hash
let aPadded = client.A.toPadded256Data()
let fakeM2 = SrpGroup.rfc5054_2048.hashAlgorithm.hash(
aPadded,
client.clientM1!,
client.sessionKey!,
rogueCertHash // Mismatched cert hash in M2
)
let fakeM2Hex = SrpFormat.bytesToHex(fakeM2)
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: fakeM2Hex)) { error in
guard case SrpError.verificationFailed(let msg) = error else {
XCTFail("Unexpected error type: \(error)")
return
}
XCTAssertTrue(msg.contains("Server evidence M2 does not match"))
}
}
func testSingleBitFlipInCertHashFails() throws {
let server = SrpServerMock(pin: pin)
let client = PortalSrpClient()
// Flip 1 bit in the genuine cert hash
var tamperedCertHash = genuineCertHash
tamperedCertHash[0] ^= 0x01
let m1Hex = try client.computeM1(
saltHex: server.saltHex,
pubBHex: server.pubBHex,
pin: pin,
tlsCertSha256: tamperedCertHash
)
let serverRes = server.verifyClient(
pubAHex: client.pubAHex,
clientM1Hex: m1Hex,
serverTlsCertSha256: genuineCertHash
)
XCTAssertFalse(serverRes.success, "Single-bit flip in TLS cert hash must fail verification")
}
}
@@ -0,0 +1,116 @@
//
// ClientAndAuthTests.swift
// PortalKitTests
//
// Verification of CredentialStorage abstractions, PortalAuth, and PortalClient models.
//
import XCTest
@testable import PortalKit
final class ClientAndAuthTests: XCTestCase {
func testInMemoryCredentialStorage() {
let storage = InMemoryCredentialStorage()
XCTAssertNil(storage.getAuthToken())
XCTAssertNil(storage.getPinnedCertSha256())
storage.save(token: "test_token_123", certSha256: "AABBCCDDEEFF")
XCTAssertEqual(storage.getAuthToken(), "test_token_123")
XCTAssertEqual(storage.getPinnedCertSha256(), "aabbccddeeff") // lowercased
storage.clear()
XCTAssertNil(storage.getAuthToken())
XCTAssertNil(storage.getPinnedCertSha256())
}
func testFileCredentialStorage() {
let tempDir = FileManager.default.temporaryDirectory
let tempFile = tempDir.appendingPathComponent("test_credentials_\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: tempFile) }
let storage = FileCredentialStorage(fileURL: tempFile)
XCTAssertNil(storage.getAuthToken())
storage.save(token: "tok_abc", certSha256: "010203040506")
XCTAssertEqual(storage.getAuthToken(), "tok_abc")
XCTAssertEqual(storage.getPinnedCertSha256(), "010203040506")
// Create fresh instance pointing to same file
let reloadStorage = FileCredentialStorage(fileURL: tempFile)
XCTAssertEqual(reloadStorage.getAuthToken(), "tok_abc")
XCTAssertEqual(reloadStorage.getPinnedCertSha256(), "010203040506")
reloadStorage.clear()
XCTAssertNil(reloadStorage.getAuthToken())
}
func testPortalAuthFacade() {
let original = PortalAuth.defaultStorage
defer { PortalAuth.defaultStorage = original }
let mockStorage = InMemoryCredentialStorage()
PortalAuth.defaultStorage = mockStorage
PortalAuth.save(token: "facade_token", certSha256: "CAFEBABE")
XCTAssertEqual(PortalAuth.token, "facade_token")
XCTAssertEqual(PortalAuth.pinnedCertSha256, "cafebabe")
PortalAuth.clear()
XCTAssertNil(PortalAuth.token)
XCTAssertNil(PortalAuth.pinnedCertSha256)
}
func testPortalClientHostNormalization() {
let c1 = PortalClient(host: "10.0.0.10")
XCTAssertEqual(c1.host, "10.0.0.10:\(PortalEndpoints.port)")
let c2 = PortalClient(host: "https://10.0.0.10:9000/")
XCTAssertEqual(c2.host, "10.0.0.10:9000")
let c3 = PortalClient(host: "http://myportal.local:\(PortalEndpoints.port)")
XCTAssertEqual(c3.host, "myportal.local:\(PortalEndpoints.port)")
}
func testPortalClientControlRequiresPairing() async {
let storage = InMemoryCredentialStorage()
let client = PortalClient(host: "127.0.0.1", credentialStorage: storage)
do {
_ = try await client.control(command: "mode Desk")
XCTFail("Should throw notPaired error")
} catch {
guard case PortalClientError.notPaired = error else {
XCTFail("Expected notPaired error, got \(error)")
return
}
}
}
func testTlsPinningChallengeEvaluationNonServerTrust() {
let space = URLProtectionSpace(
host: "localhost",
port: PortalEndpoints.port,
protocol: "https",
realm: nil,
authenticationMethod: NSURLAuthenticationMethodHTTPBasic
)
let challenge = URLAuthenticationChallenge(protectionSpace: space, proposedCredential: nil, previousFailureCount: 0, failureResponse: nil, error: nil, sender: DummyChallengeSender())
let exp = expectation(description: "Challenge evaluated")
PortalTlsPinning.evaluate(challenge: challenge, pinnedFingerprint: "somehash") { disposition, credential in
XCTAssertEqual(disposition, .cancelAuthenticationChallenge)
XCTAssertNil(credential)
exp.fulfill()
}
wait(for: [exp], timeout: 1)
}
}
private final class DummyChallengeSender: NSObject, URLAuthenticationChallengeSender {
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
func cancel(_ challenge: URLAuthenticationChallenge) {}
func performDefaultHandling(for challenge: URLAuthenticationChallenge) {}
func rejectProtectionSpaceAndContinue(with challenge: URLAuthenticationChallenge) {}
}
@@ -0,0 +1,147 @@
//
// Rfc5054VectorsTests.swift
// PortalKitTests
//
// Verification of RFC 5054 Appendix B test vectors (1024-bit group with SHA-1).
//
import XCTest
@testable import PortalKit
final class Rfc5054VectorsTests: XCTestCase {
let group = SrpGroup.rfc5054_1024
// RFC 5054 Appendix B inputs
let I = "alice"
let P = "password123"
let sHex = "BEB25379D1A8581EB5A727673A2441EE"
// RFC 5054 Appendix B expected values
let expectedKHex = "7556AA045AEF2CDD07ABAF0F665C3E818913186F"
let expectedXHex = "94B7555AABE9127CC58CCF4993DB6CF84D16C124"
let expectedVHex = """
7E273DE8696FFC4F4E337D05B4B375BEB0DDE1569E8FA00A9886D8129BADA1F1\
822223CA1A605B530E379BA4729FDC59F105B4787E5186F5C671085A1447B52A\
48CF1970B4FB6F8400BBF4CEBFBB168152E08AB5EA53D15C1AFF87B2B9DA6E04\
E058AD51CC72BFC9033B564E26480D78E955A5E29E7AB245DB2BE315E2099AFB
"""
let expectedAHex = "60975527035CF2AD1989806F0407210BC81EDC04E2762A56AFD529DDDA2D4393"
let expectedBHex = "E487CB59D31AC550471E81F00F6928E01DDA08E974A004F49E61F5D105284D20"
let expectedPubAHex = """
61D5E490F6F1B79547B0704C436F523DD0E560F0C64115BB72557EC44352E890\
3211C04692272D8B2D1A5358A2CF1B6E0BFCF99F921530EC8E39356179EAE45E\
42BA92AEACED825171E1E8B9AF6D9C03E1327F44BE087EF06530E69F66615261\
EEF54073CA11CF5858F0EDFDFE15EFEAB349EF5D76988A3672FAC47B0769447B
"""
let expectedPubBHex = """
BD0C61512C692C0CB6D041FA01BB152D4916A1E77AF46AE105393011BAF38964\
DC46A0670DD125B95A981652236F99D9B681CBF87837EC996C6DA04453728610\
D0C6DDB58B318885D7D82C7F8DEB75CE7BD4FBAA37089E6F9C6059F388838E7A\
00030B331EB76840910440B1B27AAEAEEB4012B7D7665238A8E3FB004B117B58
"""
let expectedUHex = "CE38B9593487DA98554ED47D70A7AE5F462EF019"
let expectedSHex = """
B0DC82BABCF30674AE450C0287745E7990A3381F63B387AAF271A10D233861E3\
59B48220F7C4693C9AE12B0A6F67809F0876E2D013800D6C41BB59B6D5979B5C\
00A172B4A2A5903A0BDCAF8A709585EB2AFAFA8F3499B200210DCC1F10EB3394\
3CD67FC88A2F39A4BE5BEC4EC0A3212DC346D7E474B29EDE8A469FFECA686E5A
"""
func testMultiplierK() {
let computedK = group.k
let computedKHex = SrpFormat.bytesToHex(computedK.serialize())
XCTAssertEqual(computedKHex.lowercased(), expectedKHex.lowercased(), "RFC 5054 multiplier k mismatch")
}
func testPrivateKeyX() {
guard let sData = SrpFormat.hexToBytes(sHex) else {
XCTFail("Failed to decode salt hex")
return
}
let x = SrpMath.computeRfc5054X(identity: I, password: P, salt: sData, hashAlgorithm: group.hashAlgorithm)
let xHex = SrpFormat.bytesToHex(x.serialize())
XCTAssertEqual(xHex.lowercased(), expectedXHex.lowercased(), "RFC 5054 private key x mismatch")
}
func testVerifierV() {
guard let xVal = BigUInt(expectedXHex, radix: 16) else {
XCTFail("Failed to parse expected x")
return
}
let v = SrpMath.computeVerifier(g: group.g, x: xVal, N: group.N)
let vHex = SrpFormat.bytesToHex(v.toPaddedData(byteCount: 128))
XCTAssertEqual(vHex.lowercased(), expectedVHex.lowercased(), "RFC 5054 verifier v mismatch")
}
func testPublicA() {
guard let aVal = BigUInt(expectedAHex, radix: 16) else {
XCTFail("Failed to parse expected a")
return
}
let A = SrpMath.computeA(g: group.g, a: aVal, N: group.N)
let aHex = SrpFormat.bytesToHex(A.toPaddedData(byteCount: 128))
XCTAssertEqual(aHex.lowercased(), expectedPubAHex.lowercased(), "RFC 5054 public key A mismatch")
}
func testPublicB() {
guard let bVal = BigUInt(expectedBHex, radix: 16),
let vVal = BigUInt(expectedVHex, radix: 16) else {
XCTFail("Failed to parse b or v")
return
}
let B = SrpMath.computeB(k: group.k, v: vVal, g: group.g, b: bVal, N: group.N)
let bHex = SrpFormat.bytesToHex(B.toPaddedData(byteCount: 128))
XCTAssertEqual(bHex.lowercased(), expectedPubBHex.lowercased(), "RFC 5054 public key B mismatch")
}
func testScramblerU() {
guard let aVal = BigUInt(expectedPubAHex, radix: 16),
let bVal = BigUInt(expectedPubBHex, radix: 16) else {
XCTFail("Failed to parse A or B")
return
}
let u = SrpMath.computeU(A: aVal, B: bVal, padLength: 128, hashAlgorithm: group.hashAlgorithm)
let uHex = SrpFormat.bytesToHex(u.serialize())
XCTAssertEqual(uHex.lowercased(), expectedUHex.lowercased(), "RFC 5054 scrambler u mismatch")
}
func testPremasterSecretS() {
guard let aVal = BigUInt(expectedAHex, radix: 16),
let bVal = BigUInt(expectedBHex, radix: 16),
let pubA = BigUInt(expectedPubAHex, radix: 16),
let pubB = BigUInt(expectedPubBHex, radix: 16),
let xVal = BigUInt(expectedXHex, radix: 16),
let vVal = BigUInt(expectedVHex, radix: 16),
let uVal = BigUInt(expectedUHex, radix: 16) else {
XCTFail("Failed to parse test parameters")
return
}
// Client computation: S = (B - k * (g^x mod N)) ^ (a + u * x) mod N
let clientS = SrpMath.computeClientS(
B: pubB,
k: group.k,
g: group.g,
x: xVal,
a: aVal,
u: uVal,
N: group.N
)
let clientSHex = SrpFormat.bytesToHex(clientS.toPaddedData(byteCount: 128))
XCTAssertEqual(clientSHex.lowercased(), expectedSHex.lowercased(), "Client premaster secret S mismatch")
// Server computation: S = (A * (v^u mod N)) ^ b mod N
let serverS = SrpMath.computeServerS(
A: pubA,
v: vVal,
u: uVal,
b: bVal,
N: group.N
)
let serverSHex = SrpFormat.bytesToHex(serverS.toPaddedData(byteCount: 128))
XCTAssertEqual(serverSHex.lowercased(), expectedSHex.lowercased(), "Server premaster secret S mismatch")
// Mutual agreement
XCTAssertEqual(clientS, serverS, "Client and Server premaster secrets do not match")
}
}
@@ -0,0 +1,160 @@
//
// SafetyChecksTests.swift
// PortalKitTests
//
// Verification of SRP parameter validation and safety guards (RFC 5054).
//
import XCTest
@testable import PortalKit
final class SafetyChecksTests: XCTestCase {
let validCertHash = Data(repeating: 0xaa, count: 32)
let validSaltHex = "0102030405060708090a0b0c0d0e0f10"
let validPubBHex = SrpFormat.bytesToHex(BigUInt(123456789).toPadded256Data())
let validPin = "123456"
func testInvalidSaltThrows() {
let client = PortalSrpClient()
// Empty salt
XCTAssertThrowsError(
try client.computeM1(saltHex: "", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
) { error in
guard case SrpError.invalidParameter(let m) = error else {
XCTFail("Wrong error: \(error)")
return
}
XCTAssertTrue(m.contains("salt"))
}
// Odd length hex salt
XCTAssertThrowsError(
try client.computeM1(saltHex: "abc", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
)
// Non-hex characters in salt
XCTAssertThrowsError(
try client.computeM1(saltHex: "invalid-hex-characters!!", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
)
}
func testInvalidBModuloNThrows() {
let client = PortalSrpClient()
// B = 0 -> B % N == 0
let zeroBHex = SrpFormat.bytesToHex(BigUInt(0).toPadded256Data())
XCTAssertThrowsError(
try client.computeM1(saltHex: validSaltHex, pubBHex: zeroBHex, pin: validPin, tlsCertSha256: validCertHash)
) { error in
guard case SrpError.invalidParameter(let m) = error else {
XCTFail("Wrong error: \(error)")
return
}
XCTAssertTrue(m.contains("B % N == 0"))
}
// B = N -> B % N == 0
let nBHex = SrpFormat.bytesToHex(PortalSrpClient.N.toPadded256Data())
XCTAssertThrowsError(
try client.computeM1(saltHex: validSaltHex, pubBHex: nBHex, pin: validPin, tlsCertSha256: validCertHash)
) { error in
guard case SrpError.invalidParameter(let m) = error else {
XCTFail("Wrong error: \(error)")
return
}
XCTAssertTrue(m.contains("B % N == 0"))
}
// B = 2*N -> B % N == 0
let twoNBHex = SrpFormat.bytesToHex((PortalSrpClient.N * 2).serialize())
XCTAssertThrowsError(
try client.computeM1(saltHex: validSaltHex, pubBHex: twoNBHex, pin: validPin, tlsCertSha256: validCertHash)
) { error in
guard case SrpError.invalidParameter(let m) = error else {
XCTFail("Wrong error: \(error)")
return
}
XCTAssertTrue(m.contains("B % N == 0"))
}
// Empty pubBHex
XCTAssertThrowsError(
try client.computeM1(saltHex: validSaltHex, pubBHex: "", pin: validPin, tlsCertSha256: validCertHash)
)
// Non-hex pubBHex
XCTAssertThrowsError(
try client.computeM1(saltHex: validSaltHex, pubBHex: "not_hex", pin: validPin, tlsCertSha256: validCertHash)
)
}
func testEmptyPinThrows() {
let client = PortalSrpClient()
XCTAssertThrowsError(
try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: "", tlsCertSha256: validCertHash)
) { error in
guard case SrpError.invalidParameter(let m) = error else {
XCTFail("Wrong error: \(error)")
return
}
XCTAssertTrue(m.contains("PIN"))
}
}
func testEmptyTlsCertHashThrows() {
let client = PortalSrpClient()
XCTAssertThrowsError(
try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: validPin, tlsCertSha256: Data())
) { error in
guard case SrpError.invalidParameter(let m) = error else {
XCTFail("Wrong error: \(error)")
return
}
XCTAssertTrue(m.contains("certificate"))
}
}
func testInvalidUZeroCheck() {
let _ = SrpServerMock(pin: validPin)
// If a server or client encounters u == 0, it must be rejected as an invalid scrambler.
let uZero = BigUInt(0)
XCTAssertEqual(uZero, 0)
// Verify SrpError formatting for u == 0
let err = SrpError.invalidParameter("Computed u == 0")
XCTAssertEqual(err.localizedDescription, "SRP Parameter Error: Computed u == 0")
}
func testInvalidM2Throws() throws {
let client = PortalSrpClient()
// Verify before computeM1 must fail
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "123456")) { error in
guard case SrpError.verificationFailed(let m) = error else {
XCTFail("Wrong error: \(error)")
return
}
XCTAssertTrue(m.contains("not initialized"))
}
// Initialize state
_ = try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
// Invalid hex
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "invalid-hex"))
// Odd length hex
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "abc"))
// Wrong M2
let wrongM2 = SrpFormat.bytesToHex(Data(repeating: 0x99, count: 32))
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: wrongM2)) { error in
guard case SrpError.verificationFailed(let m) = error else {
XCTFail("Wrong error: \(error)")
return
}
XCTAssertTrue(m.contains("Server evidence M2 does not match"))
}
}
}
@@ -0,0 +1,8 @@
import XCTest
@testable import PortalKit
final class SmokeTests: XCTestCase {
func testVersion() {
XCTAssertEqual(PortalKitVersion.version, "1.0.0")
}
}
@@ -0,0 +1,108 @@
//
// Srp6aExchangeTests.swift
// PortalKitTests
//
// Verification of 2048-bit SRP-6a end-to-end key exchange with mock server parameters.
//
import XCTest
@testable import PortalKit
final class Srp6aExchangeTests: XCTestCase {
func testEndToEndExchangeSuccess() throws {
let pin = "654321"
let mockCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("MOCK_CERT_LEAF_DER".data(using: .utf8)!)
let server = SrpServerMock(pin: pin)
let client = PortalSrpClient()
// Client computes M1 bound to mock TLS cert hash
let clientM1Hex = try client.computeM1(
saltHex: server.saltHex,
pubBHex: server.pubBHex,
pin: pin,
tlsCertSha256: mockCertHash
)
XCTAssertFalse(clientM1Hex.isEmpty)
XCTAssertNotNil(client.sessionKey)
// Server verifies client's A and M1
let verifyResult = server.verifyClient(
pubAHex: client.pubAHex,
clientM1Hex: clientM1Hex,
serverTlsCertSha256: mockCertHash
)
XCTAssertTrue(verifyResult.success, "Server rejected valid client proof")
XCTAssertNotNil(verifyResult.M2Hex)
XCTAssertNotNil(verifyResult.token)
// Client verifies server M2
XCTAssertNoThrow(
try client.verifyServerM2(serverM2Hex: verifyResult.M2Hex!),
"Client failed to verify genuine server M2"
)
}
func testWrongPinFailsExchange() throws {
let actualPin = "123456"
let wrongPin = "999999"
let mockCertHash = Data(repeating: 0x42, count: 32)
let server = SrpServerMock(pin: actualPin)
let client = PortalSrpClient()
// Client computes M1 with wrong PIN
let clientM1Hex = try client.computeM1(
saltHex: server.saltHex,
pubBHex: server.pubBHex,
pin: wrongPin,
tlsCertSha256: mockCertHash
)
// Server verification MUST fail
let verifyResult = server.verifyClient(
pubAHex: client.pubAHex,
clientM1Hex: clientM1Hex,
serverTlsCertSha256: mockCertHash
)
XCTAssertFalse(verifyResult.success)
XCTAssertNil(verifyResult.token)
XCTAssertNil(verifyResult.M2Hex)
XCTAssertTrue(verifyResult.errorMessage?.contains("M1 verification failed") ?? false)
}
func testMultipleRandomExchanges() throws {
// Run 5 randomized rounds to verify no modular arithmetic edge cases
for round in 1...5 {
let pin = String(format: "%06d", round * 111111 % 1000000)
let mockCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("CERT_ROUND_\(round)".data(using: .utf8)!)
let server = SrpServerMock(pin: pin)
let client = PortalSrpClient()
let m1Hex = try client.computeM1(
saltHex: server.saltHex,
pubBHex: server.pubBHex,
pin: pin,
tlsCertSha256: mockCertHash
)
let serverRes = server.verifyClient(
pubAHex: client.pubAHex,
clientM1Hex: m1Hex,
serverTlsCertSha256: mockCertHash
)
XCTAssertTrue(serverRes.success, "Round \(round) failed server verification")
guard let m2 = serverRes.M2Hex else {
XCTFail("Round \(round) missing M2")
continue
}
XCTAssertNoThrow(try client.verifyServerM2(serverM2Hex: m2), "Round \(round) failed client M2 verification")
}
}
}
+49
View File
@@ -0,0 +1,49 @@
# PortalCam for macOS (`mac2`)
Native macOS companion app (`PortalCam.app`) and CoreMediaIO Camera Extension (`CamExtension`) for streaming high-definition video and audio from Portal TV.
## Security Architecture
PortalCam uses a channel-bound, zero-knowledge authentication scheme to pair with Portal TV without sending secrets over the network or exposing connections to Man-in-the-Middle (MITM) proxies:
1. **Self-Signed ECDSA P-256 TLS**:
* Portal TV serves all endpoints over HTTPS via an on-device generated ECDSA NIST P-256 (`secp256r1`) certificate.
2. **Channel-Bound SRP-6a Handshake (RFC 5054)**:
* **Initiation**: User clicks **Pair** in the app. The app calls `POST https://<portal-ip>:5654/auth/srp/init` over an ephemeral TLS session.
* **Channel Binding**: The app extracts the presented leaf TLS certificate DER and computes:
$$\mathbf{tls\_hash} = \text{SHA256}(\text{cert\_der})$$
* **Proof $M_1$ Generation**: The user enters the 6-digit PIN displayed on the TV. The client computes:
$$M_1 = \text{SHA256}(\text{pad256}(A) \parallel \text{pad256}(B) \parallel K \parallel s \parallel \mathbf{tls\_hash})$$
and submits it to `POST https://<portal-ip>:5654/auth/srp/verify`.
* **Mutual Proof $M_2$ Verification**: The Portal TV verifies $M_1$ against its own certificate hash. If verified, it returns server proof $M_2$:
$$M_2 = \text{SHA256}(\text{pad256}(A) \parallel M_1 \parallel K \parallel \mathbf{tls\_hash})$$
along with a random 256-bit bearer token.
* **Client Verification**: The app verifies $M_2$ to confirm the Portal independently knows the shared secret before trusting the server.
### Why Active MITM Attacks Cannot Succeed
If an attacker deploys a rogue proxy:
1. The rogue proxy presents a substitute TLS certificate $C_{\text{rogue}}$ to the Mac.
2. The Mac computes $M_1$ using $\text{SHA256}(C_{\text{rogue}})$.
3. The Portal verifies $M_1$ using $\text{SHA256}(C_{\text{portal}})$.
4. Because the certificate fingerprints differ, $M_1$ verification fails on the Portal TV.
5. The attacker cannot forge $M_1$ or $M_2$ without knowing the ephemeral PIN, which is displayed solely on the physical TV screen and never leaves the room.
### Certificate Pinning & Keychain Storage
Once pairing succeeds:
* The 256-bit bearer token and the certificate's SHA-256 fingerprint are saved in the macOS Data Protection Keychain under access group `ENT9X9U544.com.kovtash.portalcam`:
* `portalAuthToken` — Bearer auth token
* `portalPinnedCertSha256` — Hex SHA-256 digest of the server's ECDSA certificate
* A fallback copy is mirrored into the shared App Group `UserDefaults` (`ENT9X9U544.com.kovtash.portalcam`) for seamless access by the sandboxed camera extension.
* `PortalPinnedSessionDelegate` evaluates all subsequent HTTPS requests (`/video.h264`, `/audio.aac`, `/control/*`). Any TLS certificate mismatch aborts the connection immediately.
## Building and Installing
```sh
cd mac2/PortalCam
xcodebuild -scheme PortalCam -configuration Debug build -derivedDataPath ./build
killall PortalCam 2>/dev/null || true
cp -R ./build/Build/Products/Debug/PortalCam.app /Applications/
open /Applications/PortalCam.app
```