This commit is contained in:
2026-09-13 12:15:36 -07:00
commit e473d00f4f
104 changed files with 16080 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
# macOS
.DS_Store
.AppleDouble
.LSOverride
Icon?
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# IDEs / editors
.idea/
*.iml
*.ipr
*.iws
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
*.swp
*~
.fleet/
# Xcode / Swift
build/
.build/
DerivedData/
*.xcuserstate
*.xcuserdata
xcuserdata/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
timeline.xctimeline
playground.xcworkspace
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
# Android / Java / Kotlin
.gradle/
local.properties
*.apk
*.ap_
*.aab
*.dex
*.class
*.idsig
captures/
.externalNativeBuild/
.cxx/
*.keystore
!debug.keystore
*.jks
# Python (e2e / tests)
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
.env
.env.*
!.env.example
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
*.egg-info/
dist/
*.egg
# Reverse-engineering dumps (jadx, etc.)
jadx-*/
**/jadx-*/
# Local secrets / credentials
secrets/
*.pem
*.p12
*.mobileprovision
credentials.json
google-services.json
# Logs / temp
*.log
*.tmp
*.temp
.cache/
+302
View File
@@ -0,0 +1,302 @@
# Testing & CLI Guide
This document covers the Portal TV / PortalCam security test suites and the
`portalkit-cli` tool used for pairing, control, and live MITM verification.
Related protocol design lives in [`portal-capability-test/README.md`](portal-capability-test/README.md)
(Authentication and Security Architecture) and [`mac2/README.md`](mac2/README.md).
---
## Overview
| Layer | Location | How to run | Scope |
|-------|----------|------------|-------|
| Android / JVM unit & integration | `portal-capability-test/` | `./run-tests.sh` | RFC 5054 vectors, SRP-6a math, channel binding, rate limits, padding, constant-time compare |
| Swift unit tests (`PortalKit`) | `mac2/PortalKit/` | `swift test` | Same crypto surface on the Mac client library |
| Live E2E + MITM | `tests/e2e_portal_test.py` | `python3 tests/e2e_portal_test.py` | Real Portal TV + `portalkit-cli` + rogue TLS proxy |
| CLI tool | `mac2/PortalKit``portalkit-cli` | see [portalkit-cli](#portalkit-cli) | Pair, status, control, MITM defense probe |
Hand-rolled SRP-6a is verified against **RFC 5054 Appendix B** test vectors on both
platforms (1024-bit group vectors from the RFC). Production pairing uses the
**RFC 5054 2048-bit** MODP group with SHA-256 hashing and TLS certificate channel
binding; those paths are covered by additional agreement / safety / MITM tests.
---
## Android / JVM test suite
### Layout
```
portal-capability-test/
├── run-tests.sh
├── src/com/portaltv/capability/
│ ├── PortalSrp.kt # server SRP-6a
│ └── PortalSrpClient.kt # client-side helpers used by tests
└── test/
├── android/util/Base64.java # JVM stub for android.util.Base64
└── com/portaltv/capability/test/
├── TestFramework.kt
├── TestRunner.kt
├── PortalSrpRfc5054Test.kt
├── PortalSrpMathTest.kt
├── PortalSrpSafetyTest.kt
├── PortalSrpRateLimitingTest.kt
├── PortalSrpChannelBindingTest.kt
├── PortalSrpPaddingTest.kt
├── PortalSrpConstantTimeTest.kt
└── PortalSrpIntegrationTest.kt
```
### Run
Requires `kotlinc` (Android Studios bundled Kotlin compiler is used if present)
and a JDK with `javac` / `java` on `PATH`.
```sh
cd portal-capability-test
./run-tests.sh
```
Expected summary (approximate counts):
```text
Total Suites: 8
Total Tests: 50
Passed: 50
Failed: 0
*** ALL 50 SRP-6a TESTS PASSED WITH 0 ERRORS ***
```
### What each suite covers
| Suite | File | Coverage |
|-------|------|----------|
| RFC 5054 Appendix B | `PortalSrpRfc5054Test.kt` | Official vectors for $k$, $x$, $v$, $A$, $B$, $u$, and premaster secret $S$ (client/server agreement + RFC match) |
| 2048-bit math | `PortalSrpMathTest.kt` | Group parameters ($N$, $g$, $k$), $K_{\text{client}} = K_{\text{server}}$, $M_1$/$M_2$, PIN variety, session entropy |
| Safety checks | `PortalSrpSafetyTest.kt` | Reject $A \bmod N = 0$, $B \bmod N = 0$, $u = 0$, malformed hex |
| Rate limiting | `PortalSrpRateLimitingTest.kt` | 3-attempt lifecycle ($3 \to 2 \to 1 \to 0$), session wipeout, recovery after one failure, expiry |
| Channel binding | `PortalSrpChannelBindingTest.kt` | Matching `tls_hash` succeeds; rogue / bit-flipped / empty hash rejected; forged $M_2$ rejected |
| Padding | `PortalSrpPaddingTest.kt` | `toPadded256` for zero, small, exact 256-byte, sign-byte, negative, oversized values |
| Constant-time compare | `PortalSrpConstantTimeTest.kt` | Equal / unequal / length-mismatch digests and keys |
| Protocol integration | `PortalSrpIntegrationTest.kt` | Simulated init → verify → bearer flow; MITM at HTTP layer; brute-force wipeout |
---
## Swift `PortalKit` unit tests
Pairing, TLS pinning, credential storage, and the high-level client live in the
`PortalKit` Swift package. `PortalCam.app` links this package; the CLI is the
same librarys executable target.
### Layout
```
mac2/PortalKit/
├── Package.swift
├── Sources/
│ ├── PortalKit/ # library (Crypto, TLS, Auth, Client, BigInt)
│ └── portalkit-cli/ # CLI executable
└── Tests/PortalKitTests/
├── Rfc5054VectorsTests.swift
├── Srp6aExchangeTests.swift
├── ChannelBindingTests.swift
├── SafetyChecksTests.swift
├── BigUIntPaddingTests.swift
├── ClientAndAuthTests.swift
└── SmokeTests.swift
```
### Run
```sh
cd mac2/PortalKit
swift test
```
Expected:
```text
Executed 33 tests, with 0 failures
```
### What each suite covers
| Suite | Coverage |
|-------|----------|
| `Rfc5054VectorsTests` | RFC 5054 Appendix B: $k$, $x$, $v$, $A$, $B$, $u$, client/server $S$ |
| `Srp6aExchangeTests` | 2048-bit end-to-end exchange, randomized sessions, wrong-PIN rejection |
| `ChannelBindingTests` | Matching cert hash succeeds; MITM / bit-flip / forged $M_2$ fail |
| `SafetyChecksTests` | Invalid salt, $B \bmod N = 0$, $u = 0$, empty PIN, bad $M_2$ |
| `BigUIntPaddingTests` | 256-byte padding, hex encode/decode, constant-time equality |
| `ClientAndAuthTests` | Credential storage, URL normalization, TLS challenge evaluation helpers |
| `SmokeTests` | Package / version sanity |
---
## Live E2E & MITM suite
### Prerequisites
- Portal TV reachable at `10.0.0.10:5654` with `com.portaltv.capability` running (HTTPS).
- `adb` authorized to the device (used to read the pairing PIN from logcat).
- Built CLI binary (see [Build portalkit-cli](#build-portalkit-cli)).
Default host and CLI path are configured at the top of `tests/e2e_portal_test.py`.
### Run
```sh
# from repo root
cd mac2/PortalKit && swift build -c release && cd ../..
python3 tests/e2e_portal_test.py
```
Expected:
```text
Ran 6 tests in ~13s
OK
```
### Scenarios
| # | Test | Asserts |
|---|------|---------|
| A | Status verification | `portalkit-cli status` → online; leaf cert SHA-256 present |
| B | Direct channel-binding defense | `portalkit-cli test-mitm` → server rejects; verdict `PASSED` |
| C | Live MITM TLS proxy | In-process proxy on `127.0.0.1:8888` presents a rogue cert; client binds $M_1$ to rogue hash; Portal returns **HTTP 401** |
| D | Pinned-cert hard-fail (all protected endpoints) | For each of `/control/state`, `/control/mode`, `/control/fixed`, `/control/desk`, `/control/events`, `/video.h264`, `/audio.aac`: client pinned to the genuine fingerprint must **abort at TLS** against the rogue proxy with **zero HTTP bytes** observed. Soft warnings / continued requests fail the test. Same for `portalkit-cli control` verbs (`state`, `mode`, `fixed`, `desk`). |
| E | Rate limiting | Three failed verifies → attempts $2 \to 1 \to 0$; fourth sees wiped session (`no_active_pairing`) |
| F | Legitimate pair + control | Read PIN from logcat → `pair` → pin cert → `control … mode Desk``{"ok":true}``control … state` shows Desk → restore `DefaultAuto` |
Scenario C is a real network MITM simulation (terminate TLS with a self-signed
rogue cert, forward upstream to the Portal over genuine TLS), not only an
in-memory hash swap.
---
## portalkit-cli
Command-line front end for `PortalKit`. Same pairing, pinning, and control
paths as `PortalCam.app`, without the UI or camera extension.
### Build portalkit-cli
```sh
cd mac2/PortalKit
swift build -c release
```
Binary:
```text
mac2/PortalKit/.build/release/portalkit-cli
```
Optional install:
```sh
cp .build/release/portalkit-cli /usr/local/bin/
```
Credentials are stored in the macOS Keychain when available, with a file fallback
at `~/.portalkit/credentials.json` (token + pinned cert SHA-256).
### Commands
#### `status <host>`
Probe service reachability and leaf certificate fingerprint; compare against any
stored pin.
```sh
portalkit-cli status 10.0.0.10:5654
```
#### `pair <host> [--pin <pin>]`
1. Connect over ephemeral HTTPS and capture leaf cert SHA-256.
2. `POST /auth/srp/init`.
3. Compute channel-bound $M_1$ from the PIN (prompted if `--pin` omitted).
4. `POST /auth/srp/verify`; verify $M_2$.
5. Persist bearer token + pinned fingerprint.
```sh
portalkit-cli pair 10.0.0.10:5654 --pin 123456
```
Read the live PIN from the TV (debug builds log it):
```sh
adb shell "logcat -d -s PortalService | grep 'SRP pairing started with PIN:' | tail -1"
```
#### `control <host> <command>`
Send camera control over **pinned** HTTPS with `Authorization: Bearer …`.
Mutations return `{"ok":true}`; `state` returns JSON camera state
`{"mode":"…","config":{…}}`. Errors are `{"error":"…","message":"…"}`.
```sh
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 mode DefaultAuto
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
```
Fails **hard** (non-zero exit, no request sent) if not paired, or if the
server cert no longer matches the pin.
#### `test-mitm <host>`
Initiate pairing, compute $M_1$ with an **altered** certificate hash, submit
verify, and expect Portal rejection (`Authentication failed (wrong PIN or MITM detected)`).
```sh
portalkit-cli test-mitm 10.0.0.10:5654
```
Successful defense prints `Verdict: PASSED`.
### Help
```sh
portalkit-cli --help
```
---
## Suggested local workflow
```sh
# 1. Crypto regressions (no device required)
cd portal-capability-test && ./run-tests.sh
cd ../mac2/PortalKit && swift test
# 2. Build CLI
swift build -c release
# 3. Deploy Portal APK (device required)
cd ../../portal-capability-test && ./deploy.sh
# 4. Live E2E including MITM proxy (device + ADB required)
cd .. && python3 tests/e2e_portal_test.py
```
---
## Notes
- **RFC vectors vs production group**: Appendix B uses the **1024-bit** RFC group
and the RFCs hash construction for those vectors. Production and most unit
tests use the **2048-bit** group with SHA-256 and
$x = \mathrm{SHA256}(s \parallel \mathrm{PIN})$ (no identity string). Both are
intentional: vectors prove the modular arithmetic; 2048-bit tests prove the
deployed protocol.
- **Channel binding**: $M_1$ and $M_2$ include $\mathrm{SHA256}(\mathrm{leaf\_cert\_DER})$.
A TLS-terminating proxy that presents a different cert cannot complete pairing.
- **Rate limit**: three failed verifies per pairing session; session and PIN are
wiped at zero attempts remaining or after the 120s expiry window.
@@ -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
```
@@ -0,0 +1,32 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.portaltv.capability">
<uses-sdk android:minSdkVersion="28" android:targetSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="com.facebook.portal.permission.SMART_CAMERA_CONTROL" />
<uses-permission android:name="com.facebook.portal.permission.SMART_CAMERA_METADATA" />
<uses-permission android:name="com.facebook.aloha.permission.READ_PORTAL_PACKAGE_POLICY" />
<uses-permission android:name="com.facebook.aloha.permission.GLOBAL_OWNER_SELECTED" />
<uses-permission android:name="com.facebook.aloha.permission.APP_FOUNDATION_SERVICE_LAUNCH" />
<uses-permission android:name="com.facebook.aloha.permission.BIND_USER_SERVICE" />
<application android:theme="@style/AppTheme" android:label="Portal Capability Test" android:largeHeap="true">
<meta-data android:name="com.facebook.portal.smartcamera.fbpermission.ACCESS_SMART_CAMERA_CONTROL_SERVICE" android:value="" />
<meta-data android:name="com.facebook.portal.smartcamera.fbpermission.ACCESS_SMART_CAMERA_METADATA_SERVICE" android:value="" />
<activity android:name=".MainActivity" android:screenOrientation="landscape" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service android:name=".PortalStreamingService" android:exported="false" android:stopWithTask="false" />
<receiver android:name=".PortalBootReceiver" android:enabled="true" android:exported="true">
<intent-filter><action android:name="android.intent.action.BOOT_COMPLETED" /></intent-filter>
</receiver>
</application>
</manifest>
+415
View File
@@ -0,0 +1,415 @@
# Portal capability test app
Source for the diagnostic APK used on the Portal TV. It checks camera enumeration,
displays frames from camera 0, samples the microphone, and exercises the Portal
Smart Camera services in `com.facebook.portal.aiservice`:
- the **external** control service (`com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE`)
with the four mode requests `DefaultAuto`, `Desk`, `Meeting`, `Fixed`
- the **external** metadata service (`com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE`)
for reading the current mode back and watching mode changes
## Files
- `src/com/portaltv/capability/MainActivity.java` — app source
- `smartcamera/src/com/portaltv/smartcamera/` — Kotlin client library (see below)
- `AndroidManifest.xml` — permissions and app declaration
- `build-apk.sh` — reproducible build (javac -> d8 -> aapt2 -> zipalign -> apksigner)
- `portal-capability-test.apk` — already-built debug APK
## Webcam streaming
## Authentication and Security Architecture
Communication between the macOS client (`PortalCam.app` / `CamExtension`) and the Portal TV service is secured using an authenticated, channel-bound transport protocol:
1. **Transport Layer**: HTTPS via `SSLServerSocket` on port 5654 with forward-secret cipher suites (`ECDHE-ECDSA-AES128-GCM-SHA256`).
2. **Device Identity**: Self-signed ECDSA NIST P-256 (`secp256r1`) certificate generated inside hardware-backed `AndroidKeyStore`.
3. **Pairing & Mutual Authentication**: RFC 5054 SRP-6a (2048-bit MODP group) with **cryptographic TLS channel binding**.
4. **Post-Pairing Enforcement**: Strict TLS certificate SHA-256 fingerprint pinning in macOS Keychain, combined with 256-bit Bearer token authentication.
```
Mac (PortalCam.app) Portal TV
| |
| 1. POST /auth/srp/init (HTTPS) |
|------------------------------------------------->| Generates 6-digit PIN, salt s,
| | derives v = g^x mod N, B = (kv + g^b) mod N
| 2. { pairingId, salt, B } |
|<-------------------------------------------------| Displays PIN on TV UI
Extracts server TLS cert DER, |
computes tls_hash = SHA256(cert_der) |
User enters 6-digit PIN |
Computes x, A = g^a mod N, u, S, K |
M1 = SHA256(A || B || K || s || tls_hash) |
| |
| 3. POST /auth/srp/verify { pairingId, A, M1 } |
|------------------------------------------------->| Computes u, S, K
| | Verifies M1 using Portal's tls_hash
| | (Rate limit: max 3 attempts)
| 4. { M2, token } | Computes M2 = SHA256(A || M1 || K || tls_hash)
|<-------------------------------------------------|
Verifies M2 using tls_hash |
Pins tls_hash & saves token in Keychain |
| |
|==================================================|
| Subsequent Media & Control (HTTPS) |
| Strict Pinning: cert_sha256 == pinned_hash |
| Authorization: Bearer <token> |
|==================================================|
```
### Cryptographic & Protocol Choices
#### 1. Server Certificate: ECDSA P-256 (`secp256r1`)
* **Why Elliptic Curve over RSA**:
* **Handshake Speed & CPU**: EC scalar point multiplication is dramatically lighter on the Portal's Snapdragon 835 ARM cores than 2048-bit modular exponentiation.
* **Certificate Size**: The ECDSA certificate is only **352 bytes** DER (vs ~1.5 KB for RSA-2048), keeping the TLS handshake packet well within a single TCP MTU.
* **Key Generation Time**: Key generation in `AndroidKeyStore` takes ~100 ms compared to 1.53 seconds for RSA.
* **Clean Platform Integration**: Android Keymaster implements `SHA256withECDSA` natively, avoiding raw digest (`NONEwithRSA`) driver inconsistencies present on Android 9.
* **Storage**: Stored under alias `portalcam_tls_ec_p256` in `AndroidKeyStore` with private key non-exportable and protected for signing.
#### 2. TLS Protocol Version & Cipher Suite
* **Protocol**: TLS 1.2 with opportunistic negotiation up to TLS 1.3 (`enabledProtocols = arrayOf("TLSv1.3", "TLSv1.2")`).
* *Android 9 Platform Context*: Conscrypt on Android 9 (API 28) enables TLS 1.3 for client sockets, while server-mode `SSLServerSocket` natively negotiates `TLSv1.2`.
* **Cipher Suite**: `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256` providing authenticated encryption (AEAD) and Ephemeral Diffie-Hellman forward secrecy.
#### 3. Password-Authenticated Key Exchange: RFC 5054 SRP-6a
* **Group Parameters**: 2048-bit MODP prime group ($N$, generator $g = 2$, multiplier $k = \text{SHA256}(N \parallel g)$).
* **PIN Entropy**: 6-digit random decimal number ($10^6$ combinations).
* **Key Derivation**: $x = \text{SHA256}(s \parallel \text{PIN})$, with a 16-byte cryptographically secure random salt $s$.
* **Zero-Knowledge Property**: The PIN is never transmitted over the wire in cleartext, hashed, or encrypted form. Mutual authentication proves knowledge of the PIN without exposing it to eavesdroppers.
---
### Cryptographic Channel Binding & MITM Prevention
Standard self-signed TLS pairing typically suffers from a **Trust-on-First-Use (TOFU)** vulnerability: a client connecting to an untrusted self-signed certificate has no initial root of trust, making it susceptible to an active Man-in-the-Middle (MITM) proxy that injects its own TLS certificate.
Our implementation eliminates this vulnerability by cryptographically binding the transport layer into the SRP evidence proofs:
$$\mathbf{tls\_hash} = \text{SHA256}(\text{LeafCertificate}_{\text{DER}})$$
$$M_1 = \text{SHA256}(\text{pad256}(A) \parallel \text{pad256}(B) \parallel K \parallel s \parallel \mathbf{tls\_hash})$$
$$M_2 = \text{SHA256}(\text{pad256}(A) \parallel M_1 \parallel K \parallel \mathbf{tls\_hash})$$
#### Why an Active MITM Cannot Succeed:
1. Suppose an attacker places a rogue proxy between the Mac and the Portal TV.
2. The proxy terminates TLS to the Mac using a rogue certificate $C_{\text{rogue}}$, and opens a second TLS connection to the Portal TV using $C_{\text{portal}}$.
3. The Mac computes $\text{tls\_hash}_{\text{mac}} = \text{SHA256}(C_{\text{rogue}})$.
4. The Mac computes evidence $M_1$ incorporating $\text{tls\_hash}_{\text{mac}}$ and sends it to the server.
5. The Portal TV verifies $M_1$ using its own certificate hash: $\text{tls\_hash}_{\text{portal}} = \text{SHA256}(C_{\text{portal}})$.
6. Because $C_{\text{rogue}} \neq C_{\text{portal}}$, the hashes differ: $\text{tls\_hash}_{\text{mac}} \neq \text{tls\_hash}_{\text{portal}}$.
7. **Verification fails immediately** on the Portal TV. The server aborts the handshake, increments the failed attempt counter, and refuses to issue a bearer token.
8. The attacker cannot forge a valid $M_1$ or $M_2$ because computing either proof requires the shared session key $K$, which requires knowledge of the PIN displayed exclusively on the physical TV screen.
---
### Certificate Pinning & Key Storage
Once the client successfully verifies $M_2$:
1. **Keychain Storage**:
* The 256-bit bearer token is stored under `portalAuthToken`.
* The server certificate's SHA-256 fingerprint is stored under `portalPinnedCertSha256`.
* Stored in the macOS Data Protection Keychain with shared access group `ENT9X9U544.com.kovtash.portalcam` and synchronized to the App Group `UserDefaults` (`ENT9X9U544.com.kovtash.portalcam`) for sandboxed access by the CMIO camera extension.
2. **Strict Pinning Enforcement**:
* `PortalPinnedSessionDelegate` intercepts `URLAuthenticationChallenge` on all subsequent HTTPS sessions (`/video.h264`, `/audio.aac`, `/control/*`).
* It extracts the server leaf certificate via `SecTrustCopyCertificateChain`, computes its SHA-256 digest, and performs a constant-time comparison against `PortalAuth.pinnedCertSha256`.
* Any certificate change (e.g. Rogue CA injection, LAN redirect, proxy) causes `completionHandler(.cancelAuthenticationChallenge, nil)`, severing the connection immediately.
---
### Brute-Force Rate Limiting
* The Portal TV strictly limits failed pairing attempts to **a maximum of 3**.
* If 3 failed `verify` attempts occur, or if the 120-second expiration window elapses:
* The active pairing session, PIN, salt, and verifier are completely erased from memory.
* Future attempts are rejected with `400 / 401`.
* **Security Margin**: With $10^6$ possible 6-digit PINs and 3 maximum attempts, the probability of an attacker guessing the PIN within the lifetime of a pairing session is:
$$P(\text{guess}) = \frac{3}{10^6} = 0.0003\%$$
### Verified milestone
The foreground streaming service is installed and verified on Portal TV. It
survives a device reboot, starts its listener automatically, starts camera or
microphone capture on the first client, and tears capture down when the last
client disconnects. Returning the Portal app to the foreground restores the
service path for new clients.
The app runs an HTTPS server on port 5654 (starts on launch), encoding with the
Snapdragon 835 hardware codecs (H.264 via `OMX.qcom.video.encoder.avc`, AAC-LC
via MediaCodec from the mic tee). All streaming and control endpoints require an `Authorization: Bearer <token>` header obtained via the SRP pairing handshake:
- `https://<portal-ip>:5654/` — info page
- `https://<portal-ip>:5654/stream.ts`**MPEG-TS: H.264 + AAC, synced (recommended)**
- `https://<portal-ip>:5654/video.h264` — raw H.264 Annex B, 720p30 @ ~2.5 Mbps
- `https://<portal-ip>:5654/audio.aac` — AAC ADTS, 48 kHz mono @ 64 kbps
Camera control (Bearer auth). Success bodies are always current state
`{"mode":"<Name>","config":{…}}`; failures are
`{"error":"<code>","message":"<text>"}` with a non-2xx status:
| Path | Purpose |
|---|---|
| `GET /control/state` | Read current mode + config |
| `GET /control/mode?mode=DefaultAuto\|Desk\|Meeting\|Fixed` | Switch mode; returns new state |
| `GET /control/fixed?x=&y=&scale=` | Apply Fixed crop; returns new state |
| `GET /control/desk?tightness=` | Apply Desk framing tightness; returns new state |
`config` is mode-specific: empty for DefaultAuto/Meeting; Fixed includes
`centerX`/`centerY`/`scale`; Desk includes optional tuning fields such as
`framingTightness`.
The TS stream is muxed on-device (hand-rolled PAT/PMT/PES/TS with PCR and
encoder PTS; MediaMuxer has no TS support on API 28). SPS/PPS are re-sent
before every IDR, so players can join mid-stream. This is the endpoint that
behaves in players — raw elementary streams make them flaky:
```sh
curl -k -H "Authorization: Bearer <token>" https://<portal-ip>:5654/stream.ts | ffplay -
```
Watch directly with ffplay or mpv (accepting pinned cert or with bearer token):
```sh
curl -k -H "Authorization: Bearer <token>" https://<portal-ip>:5654/video.h264 | \
ffplay -framerate 30 -probesize 500k -analyzeduration 500ms -fflags nobuffer -flags low_delay -f h264 -
```
-f h264 http://<portal-ip>:5654/video.h264
ffplay -nodisp -probesize 50k -analyzeduration 200ms -f aac http://<portal-ip>:5654/audio.aac
```
Both at once, one window (live remux to MPEG-TS; wallclock timestamps keep the
muxer happy since raw H.264 carries no timestamps):
```sh
ffmpeg -use_wallclock_as_timestamps 1 -f h264 -i http://<portal-ip>:5654/video.h264 \
-use_wallclock_as_timestamps 1 -f aac -i http://<portal-ip>:5654/audio.aac \
-c copy -f mpegts - | ffplay -
```
Use as a virtual webcam/mic on the computer:
```sh
# Linux (v4l2loopback):
ffmpeg -f h264 -i http://<portal-ip>:5654/video.h264 -pix_fmt yuv420p -f v4l2 /dev/video2
ffmpeg -f aac -i http://<portal-ip>:5654/audio.aac -f pulse portal-mic
# macOS: add a Media Source in OBS pointing at /video.h264 (uncheck "local file"),
# then start OBS Virtual Camera.
```
Multiple clients are supported; a slow video client is dropped rather than
allowed to corrupt its H.264 stream. New video clients are sent cached SPS/PPS
and a fresh IDR is requested from the encoder.
(USB gadget / UVC webcam mode is not possible on this device: Android 9 has no
UVC function in UsbDeviceManager, the kernel exposes no configfs gadget, and
configuring it would require root on a locked bootloader.)
## Smart Camera Kotlin library (`smartcamera/`)
Drop-in client wrapping the raw binder protocol below. Requires only
`kotlinx-coroutines` and the two manifest permissions.
```kotlin
val camera = SmartCameraController(context, lifecycleScope)
camera.start() // binds both services, subscribes mode + crop
camera.state.collect { s -> // connection, current mode, live crop window
Log.i("cam", "${s.connection} mode=${s.mode} crop=${s.cropWindow}")
}
camera.desk.activate() // per-mode sub-controllers
camera.desk.activate(DeskModeController.Tuning(framingTightness = 0.8f))
camera.fixed.setCrop(CropConfig(centerX = 0.3f, scale = 0.5f)) // ~2x zoom, left third
camera.fixed.pan(dx = 0.05f, dy = 0f)
camera.fixed.zoomBy(1.25f)
camera.auto.activate()
camera.meeting.isActive.collect { active -> ... }
camera.stop()
```
- `SmartCameraController` owns one control session (the service only honors the
top priority-queue connection), keeps death tokens alive, and rebinds on
service death. State comes from `subscribeModeChanges` / `subscribeFrameMetadata("crop")`.
- `ModeController` subclasses (`auto`, `desk`, `meeting`, `fixed`) expose
`isActive` (from the mode subscription) plus mode-specific params; `fixed`
also exposes `appliedCrop` from the crop subscription.
- Only DefaultAuto/Desk/Meeting/Fixed are whitelisted on the external API.
Build and install:
```sh
./build-apk.sh
adb install -r -g portal-capability-test.apk
```
## Reaching the private APIs
Reverse engineering `aiservice.apk` (see `jadx-ai/`) shows the external services
are gated by nothing more than a normal-level manifest permission:
### External services: normal-level manifest permission (SOLVED)
`SmartCameraControlService` / `SmartCameraMetadataService` are protected only by
`android:permission="com.facebook.portal.permission.SMART_CAMERA_CONTROL"` /
`..._METADATA`, both defined by `aiservice` with `protectionLevel="0x0"` (normal).
Any app installed after `aiservice` that declares the uses-permission is granted
it. This app declares them, and all four mode requests are accepted — verified on
device. No exploit needed. No runtime FbPermission check exists on the external
path (`SmartCameraControlService` never calls `SmartCameraIPCPermissionManager`).
Practical notes for the external control service (all learned on device):
- **One connection only.** Each `connect()` creates a `Connection` in a priority
queue; only the top connection is *enabled* and `setMode` on any other is
silently dropped (`Enabled: false; returning...` in logcat). Priority: the
camera-editor package always wins, otherwise the client with the foreground
process wins. Reuse a single session — the app caches its `IControlSession`.
- The crop/zoom applies to Portal's AIService pipeline (calls, photobooth),
not to the app's own raw Camera2 preview.
## Smart Camera external API reference
All discovered by decompiling `aiservice.apk` and verified on device.
Binder wire format notes: every call writes its interface token first;
`ModeSetting` parcels as `writeInt(1) + writeString(name) + writeBundle(params)`;
`MetadataBundle` parcels as a bare `Bundle`; replies are
`readException()` then `readInt()` flag (1 = payload follows).
### Services
| Service class | Bind action | Permission (normal level) |
|---|---|---|
| `SmartCameraControlService` | `com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE` | `com.facebook.portal.permission.SMART_CAMERA_CONTROL` |
| `SmartCameraMetadataService` | `com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE` | `com.facebook.portal.permission.SMART_CAMERA_METADATA` |
Both live in package `com.facebook.portal.aiservice` (set it explicitly on the
bind intent).
### Control: `ISmartCameraControlService`
| # | Method | Args | Returns |
|---|---|---|---|
| 1 | `getVersion()` | — | int (1) |
| 2 | `connect(IBinder deathToken)` | any Binder (strong-ref it client-side!) | `ISmartCameraControlConnection` |
### Control: `ISmartCameraControlConnection`
| # | Method | Notes |
|---|---|---|
| 1 | `close()` | |
| 2 | `requestControls(IControlStateCallback)` | returns `IControlSession`; pass any Binder as the callback (only used to signal "control revoked") |
| 3/4 | `subscribeAvailabilityChanges` / `unsubscribe…` | deprecated; immediately reports available |
| 5 | `setMode(ModeSetting)` | deprecated direct path, same whitelist as sessions |
| 6 | `isSessionPropertySupported(String)` | always false |
| 7 | `setSessionProperties(...)` | throws (unimplemented) |
### Control: `IControlSession` (from `requestControls`)
| # | Method | Notes |
|---|---|---|
| 1 | `close()` | closes the whole connection |
| 3 | `setMode(ModeSetting)` | the call this app uses; whitelist enforced |
| 4 | `isSessionPropertySupported(String)` | always false |
| 5 | `setSessionProperties(...)` | delegates, then throws |
### Modes (`ModeSetting_<name>` + Bundle params)
Whitelist (all an external caller may set): **DefaultAuto, Desk, Meeting, Fixed**.
| Mode | Bundle params | Effect |
|---|---|---|
| `DefaultAuto` | — | normal auto tracking |
| `Meeting` | — | group framing; forces `PERFORMANCE_NO_TRACKER` resource profile |
| `Desk` | all optional floats: `additional_stable_framing_tightness` (zoom-like framing tightness), `tracking_response_delay_percentage`, `tracking_sensitivity_percentage`, `transition_speed_percentage` | desk framing; mobileconfig defaults when absent; same resource profile as Meeting |
| `Fixed` | **required**: `camera.relative_crop_center_x` / `_y` (float, normalized), `camera.relative_crop_scale` (float, window size vs full frame: 1.0 = full, 0.5 ≈ 2x zoom), `camera.relative_exposure_region` (RectF, nullable), `camera.relative_face_metering_regions` (ArrayList<RectF>, nullable) | static crop, no tracking; missing keys throw `Missing key` server-side; keep center within `[scale/2, 1-scale/2]` |
Internal-only modes (whitelist-blocked externally, see "gated" section):
`Spotlight` (`person_id` int — follow one tracked person; reads back as
`BasicSpotlight`), `SpotlightStoryTime` (`person_id` + optional
`target_rect_landscape/portrait` RectF + `target_regions` = `head`/`shoulders`
list), `FullWide`, `Storytime`, `NoOp`.
### Metadata: `ISmartCameraMetadataService`
| # | Method | Args | Returns |
|---|---|---|---|
| 1 | `getVersion()` | — | int |
| 2 | `connect(IBinder deathToken)` | any Binder | `ISmartCameraMetadataConnection` |
### Metadata: `ISmartCameraMetadataConnection`
| # | Method | Args | Returns |
|---|---|---|---|
| 1 | `close()` | | |
| 2 | `getMode()` | — | current `ModeSetting` or null |
| 3 | `subscribeModeChanges(IModeListener)` | listener binder | current mode + push on every change |
| 4 | `unsubscribeModeChanges(...)` | | |
| 5 | `getFrameMetadata(List<String> topics)` | topic names | `MetadataBundle` snapshot |
| 6 | `subscribeFrameMetadata(IStreamingMetadataReceiver, List<String> topics, float rateHz)` | receiver + topics + rate | initial snapshot + push at rate |
| 7 | `unsubscribeFrameMetadata(...)` | | |
Callback binder stubs (implement `onTransact` code 1; skip token via
`readInt(); readString()`, then `readInt()` flag, then payload):
- `IModeListener` (`com.facebook.portal.smartcamera.external.metadata.IModeListener`):
payload = `ModeSetting`
- `IStreamingMetadataReceiver` (`com.facebook.portal.smartcamera.metadata.IStreamingMetadataReceiver`):
payload = `MetadataBundle`
External frame-metadata topics: `crop` (RectF — the live crop window; watch it
to *see* tracking happen), `frame_orientation`, `frame_rotation`,
`effective_device_rotation`, `full_fov_aspect_ratio`.
Unknown topics throw `IllegalArgumentException: Unknown frame metadata topic`.
### Person-selection modes are gated to Meta-signed apps
The internal-only modes listed in the mode table above are gated two ways:
1. The **external** control service hardcodes a whitelist in
`AidlConnection.setMode` (`ModeSetting.A02` = DefaultAuto/Desk/Fixed/
Meeting) applied to *every* caller; bytecode inspection confirms the
session path (`IControlSession.setMode`) delegates to that same checked
method, so there is no bypass. Other modes fail with
`IllegalArgumentException: Mode not supported`.
2. The **internal** control service (`SMART_CAMERA_INTERNAL_CONTROL_SERVICE`)
has *no* whitelist — its `setMode` accepts every mode — but every method
first calls `SmartCameraIPCPermissionManager
.enforceAccessSmartCameraControlPermission()`, which requires the
caller's package to declare the
`com.facebook.portal.smartcamera.fbpermission.ACCESS_SMART_CAMERA_*`
meta-data **and be signed with Meta's key**. A sideloaded app cannot
satisfy the signature check, so Spotlight/person-selection is
unreachable. (Valid `person_id`s would also require the internal
world-metadata topic `person_ids`.)
(Two apparent Spotlight successes observed during testing were not
reproducible under controlled conditions; `BasicSpotlight` is also what the
Portal's own auto-tracking reports as the current mode when it follows
someone, which likely explains the readings.)
**Tracked-people data is likewise internal-only.** The world-model topics —
`person_count`, `person_ids`, `person_biometric_ids`, `person_distance_ft`,
`person_alignment_ratio`, `is_hand_waving`, `is_arm_raised`,
`is_voice_activity_detected`, `update_time_ms` — are served by
`getWorldMetadata` / `subscribeWorldMetadata` (transacts 8/9), which exist only
on the internal metadata connection (signature-gated). The external binder stub
only dispatches transactions 1-7, so there is no transaction-number trick to
reach them.
### Internal services: out of scope
`SmartCameraInternalControlService`, `SmartCameraInternalMetadataService` and
`AiInternalControlService` enforce a runtime FbPermission check requiring Meta's
signing key, so they are not reachable from a sideloaded app and are not used
here.
## Notes
- The `camera_denied_package=com.portaltv.capability` flag seen in
`platform_state_service` output is set by Portal's privacy layer the first
time the app opens the camera without the privacy system expecting it; it does
not affect the Smart Camera service path.
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Build portal-capability-test.apk from src/ with the plain SDK toolchain
# (javac -> d8 -> aapt2 -> zipalign -> apksigner). No Gradle.
set -euo pipefail
cd "$(dirname "$0")"
SDK="$HOME/Library/Android/sdk"
PLATFORM="$SDK/platforms/android-35/android.jar"
BT="$SDK/build-tools/37.0.0"
OUT=portal-capability-test.apk
rm -rf build/classes build/apk build/compiled-res.zip build/unaligned.apk build/aligned.apk
mkdir -p build/classes build/apk
echo "== kotlinc =="
KOTLIN="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc"
KOTLIN_LIB="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/lib"
KOTLIN_STDLIB="$KOTLIN_LIB/kotlin-stdlib-jdk8.jar"
KOTLIN_STDLIB_BASE="$KOTLIN_LIB/kotlin-stdlib.jar"
COROUTINES_CORE="$KOTLIN_LIB/kotlinx-coroutines-core-jvm.jar"
# Android Main dispatcher (optional at compile; needed at runtime for Dispatchers.Main)
COROUTINES_ANDROID=$(find "$HOME/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-coroutines-android" -name 'kotlinx-coroutines-android-*.jar' 2>/dev/null | sort -V | tail -1)
if [[ -z "${COROUTINES_ANDROID}" || ! -f "${COROUTINES_ANDROID}" ]]; then
echo "error: kotlinx-coroutines-android jar not found under ~/.gradle/caches" >&2
exit 1
fi
KT_CP="$PLATFORM:$COROUTINES_CORE:$COROUTINES_ANDROID"
"$KOTLIN" -cp "$KT_CP" -d build/kotlin.jar \
$(find src smartcamera/src -name '*.kt')
echo "== javac =="
javac --release 8 -classpath "$PLATFORM:build/kotlin.jar" \
-d build/classes $(find src -name '*.java')
echo "== d8 =="
"$BT/d8" --lib "$PLATFORM" --min-api 28 --output build/apk \
$(find build/classes -name '*.class') build/kotlin.jar \
"$KOTLIN_STDLIB" "$KOTLIN_STDLIB_BASE" "$COROUTINES_CORE" "$COROUTINES_ANDROID"
echo "== aapt2 compile+link =="
"$BT/aapt2" compile --dir res -o build/compiled-res.zip
"$BT/aapt2" link -o build/unaligned.apk \
-I "$PLATFORM" \
--manifest AndroidManifest.xml \
--java src \
build/compiled-res.zip
# aapt2 --java regenerates R.java under src/; we don't use R, ignore it.
# Repack dex into the apk.
cp build/unaligned.apk build/withdex.apk
( cd build/apk && zip -q -u ../withdex.apk *.dex )
echo "== zipalign + sign =="
"$BT/zipalign" -f 4 build/withdex.apk build/aligned.apk
"$BT/apksigner" sign --ks ~/.android/debug.keystore --ks-pass pass:android \
--out "$OUT" build/aligned.apk
echo "built $OUT"
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
./build-apk.sh
echo "== Installing APK via ADB =="
adb install -r portal-capability-test.apk
echo "== Starting MainActivity / PortalStreamingService =="
adb shell am start -n com.portaltv.capability/.MainActivity
echo "== Done =="
@@ -0,0 +1 @@
<resources><style name="AppTheme" parent="android:style/Theme.Material.NoActionBar"><item name="android:fontFamily">sans</item><item name="android:colorAccent">#80cbc4</item></style></resources>
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Standalone test runner for PortalSrp and security mechanisms on Android/JVM
set -euo pipefail
cd "$(dirname "$0")"
# Locate kotlinc
if [ -x "/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc" ]; then
KOTLIN="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc"
KOTLIN_LIB="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/lib"
elif command -v kotlinc >/dev/null 2>&1; then
KOTLIN="$(command -v kotlinc)"
KOTLIN_LIB="$(dirname "$KOTLIN")/../lib"
else
echo "Error: kotlinc compiler not found" >&2
exit 1
fi
KOTLIN_STDLIB="$KOTLIN_LIB/kotlin-stdlib.jar"
KOTLIN_STDLIB_JDK8="$KOTLIN_LIB/kotlin-stdlib-jdk8.jar"
BUILD_DIR="build/test-classes"
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"
echo "== [1/3] Compiling JVM Base64 adapter =="
javac -d "$BUILD_DIR" test/android/util/Base64.java
echo "== [2/3] Compiling PortalSrp, PortalSrpClient, and test suites with kotlinc =="
"$KOTLIN" \
-cp "$BUILD_DIR" \
-d "$BUILD_DIR" \
src/com/portaltv/capability/PortalSrp.kt \
src/com/portaltv/capability/PortalSrpClient.kt \
$(find test -name '*.kt')
echo "== [3/3] Running Portal SRP-6a Unit & Integration Test Suites =="
CP="$BUILD_DIR:$KOTLIN_STDLIB"
if [ -f "$KOTLIN_STDLIB_JDK8" ]; then
CP="$CP:$KOTLIN_STDLIB_JDK8"
fi
java -cp "$CP" com.portaltv.capability.test.TestRunnerKt "$@"
@@ -0,0 +1,117 @@
package com.portaltv.smartcamera
import android.graphics.RectF
import android.os.Bundle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Controls one Smart Camera mode and exposes whether it is currently active
* (tracked via the controller's mode-change subscription).
*
* Note: the external API whitelists DefaultAuto, Desk, Meeting and Fixed only;
* other modes (Spotlight, FullWide, Storytime) are rejected server-side.
*/
abstract class ModeController(protected val controller: SmartCameraController) {
/** Full AIDL mode name, e.g. "ModeSetting_Desk". */
abstract val modeName: String
/** True while the camera pipeline reports this mode as current. */
val isActive: Flow<Boolean> =
controller.state.map { it.mode == modeName }.distinctUntilChanged()
/** Switch the camera to this mode. Returns false if the call could not be delivered. */
abstract suspend fun activate(): Boolean
}
/** DefaultAuto: Portal's standard auto-framing/tracking. No parameters. */
class AutoModeController internal constructor(controller: SmartCameraController) :
ModeController(controller) {
override val modeName = "ModeSetting_DefaultAuto"
override suspend fun activate(): Boolean = controller.setMode(modeName)
}
/** Meeting: static wide framing for conference calls. No parameters. */
class MeetingModeController internal constructor(controller: SmartCameraController) :
ModeController(controller) {
override val modeName = "ModeSetting_Meeting"
override suspend fun activate(): Boolean = controller.setMode(modeName)
}
/**
* Desk: close-up framing of a desk area with tracking.
* All tuning values are optional; null leaves the service default in place.
*/
class DeskModeController internal constructor(controller: SmartCameraController) :
ModeController(controller) {
override val modeName = "ModeSetting_Desk"
data class Tuning(
/** Zoom-like tightness of the stable framing. */
val framingTightness: Float? = null,
val trackingResponseDelayPct: Float? = null,
val trackingSensitivityPct: Float? = null,
val transitionSpeedPct: Float? = null,
)
/** Last tuning sent (or defaults). The service does not report these back. */
private val _tuning = MutableStateFlow(Tuning())
val tuning: StateFlow<Tuning> = _tuning.asStateFlow()
override suspend fun activate(): Boolean = activate(_tuning.value)
suspend fun activate(tuning: Tuning): Boolean {
_tuning.value = tuning
val b = Bundle()
tuning.framingTightness?.let { b.putFloat("additional_stable_framing_tightness", it) }
tuning.trackingResponseDelayPct?.let { b.putFloat("tracking_response_delay_percentage", it) }
tuning.trackingSensitivityPct?.let { b.putFloat("tracking_sensitivity_percentage", it) }
tuning.transitionSpeedPct?.let { b.putFloat("transition_speed_percentage", it) }
return controller.setMode(modeName, b)
}
}
/**
* Fixed: static crop of the sensor, no tracking.
*
* [crop] is the requested (clamped) crop; [appliedCrop] is the live crop window
* reported back by the camera pipeline (requires start(trackCrop = true)).
*/
class FixedModeController internal constructor(controller: SmartCameraController) :
ModeController(controller) {
override val modeName = "ModeSetting_Fixed"
private val _crop = MutableStateFlow(CropConfig())
val crop: StateFlow<CropConfig> = _crop.asStateFlow()
val appliedCrop: Flow<RectF?> =
controller.state.map { it.cropWindow }.distinctUntilChanged()
override suspend fun activate(): Boolean = setCrop(_crop.value)
suspend fun setCrop(config: CropConfig): Boolean {
val c = config.clamped()
_crop.value = c
val b = Bundle().apply {
putFloat("camera.relative_crop_center_x", c.centerX)
putFloat("camera.relative_crop_center_y", c.centerY)
putFloat("camera.relative_crop_scale", c.scale)
// Required keys; null = leave to the pipeline.
putParcelable("camera.relative_exposure_region", null)
putParcelableArrayList("camera.relative_face_metering_regions", null)
}
return controller.setMode(modeName, b)
}
/** Moves the crop center by [dx], [dy] (normalized, clamped to the frame). */
suspend fun pan(dx: Float, dy: Float): Boolean =
setCrop(_crop.value.let { it.copy(centerX = it.centerX + dx, centerY = it.centerY + dy) })
/** [factor] > 1 zooms in, < 1 zooms out. */
suspend fun zoomBy(factor: Float): Boolean =
setCrop(_crop.value.let { it.copy(scale = it.scale / factor) })
}
@@ -0,0 +1,65 @@
package com.portaltv.smartcamera
import android.graphics.RectF
/** Failure talking to the Portal Smart Camera service. */
class SmartCameraException(message: String, cause: Throwable? = null) : Exception(message, cause)
enum class ConnectionState { DISCONNECTED, CONNECTING, READY }
/**
* Snapshot of everything the controller tracks.
*
* @property mode full AIDL mode name, e.g. "ModeSetting_Desk". Updated live via
* subscribeModeChanges once the metadata service is connected.
* @property cropWindow live crop window (normalized coordinates) reported by the
* camera pipeline via the "crop" frame-metadata topic. Reflects auto-tracking
* and applied Fixed crops. Null until the first metadata frame arrives.
*/
data class CameraState(
val connection: ConnectionState = ConnectionState.DISCONNECTED,
val mode: String? = null,
val cropWindow: RectF? = null,
) {
val isReady: Boolean get() = connection == ConnectionState.READY
}
/**
* Event-driven control view: mode + locally owned Fixed/Desk params.
* Emitted whenever mode metadata, Fixed crop, or Desk tuning changes.
*/
data class ControlSnapshot(
val connection: ConnectionState = ConnectionState.DISCONNECTED,
/** Full AIDL name, e.g. `ModeSetting_Desk`, or null if unknown. */
val mode: String? = null,
val fixedCrop: CropConfig = CropConfig(),
val deskTuning: DeskModeController.Tuning = DeskModeController.Tuning(),
val appliedCrop: RectF? = null,
) {
/** Short mode name without `ModeSetting_` prefix. */
val shortMode: String?
get() = mode?.removePrefix("ModeSetting_")
}
/**
* Requested crop for Fixed mode. All values normalized to [0, 1].
*
* @property scale 1.0 = full frame, 0.5 ~= 2x zoom.
*/
data class CropConfig(
val centerX: Float = 0.5f,
val centerY: Float = 0.5f,
val scale: Float = 1.0f,
) {
/** Clamps scale to [MIN_SCALE, 1] and keeps the crop window inside the frame. */
fun clamped(): CropConfig {
val s = scale.coerceIn(MIN_SCALE, 1f)
return CropConfig(
centerX.coerceIn(s / 2f, 1f - s / 2f),
centerY.coerceIn(s / 2f, 1f - s / 2f),
s,
)
}
companion object { const val MIN_SCALE = 0.1f }
}
@@ -0,0 +1,389 @@
package com.portaltv.smartcamera
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.RectF
import android.os.Binder
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import com.portaltv.smartcamera.internal.MetadataReceiverBinder
import com.portaltv.smartcamera.internal.ModeListenerBinder
import com.portaltv.smartcamera.internal.Rpc
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Maintains the connection to Portal's Smart Camera services (control + metadata)
* and tracks camera state via subscriptions.
*
* The consuming app must declare these (normal-level) manifest permissions:
* - [PERMISSION_CONTROL]
* - [PERMISSION_METADATA]
*
* Usage:
* ```
* val camera = SmartCameraController(context, lifecycleScope)
* camera.start()
* camera.desk.activate()
* camera.fixed.setCrop(CropConfig(centerX = 0.3f, scale = 0.5f))
* ```
*
* Implementation notes (verified on Portal TV / ripley):
* - Exactly ONE control session is kept: every connect() creates a Connection in
* the service's priority queue and only the top one is enabled; setMode on
* others is silently dropped.
* - Strong references to the death-token binders are kept for the lifetime of
* the controller; if they are GC'd the service kills the connection.
*/
class SmartCameraController(
private val context: Context,
private val scope: CoroutineScope,
) {
companion object {
private const val TAG = "SmartCamera"
const val AISERVICE_PACKAGE = "com.facebook.portal.aiservice"
const val CONTROL_ACTION = "com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE"
const val METADATA_ACTION = "com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE"
const val PERMISSION_CONTROL = "com.facebook.portal.permission.SMART_CAMERA_CONTROL"
const val PERMISSION_METADATA = "com.facebook.portal.permission.SMART_CAMERA_METADATA"
const val TOPIC_CROP = "crop"
private const val TOKEN_CONTROL_SERVICE =
"com.facebook.portal.smartcamera.external.control.ISmartCameraControlService"
private const val TOKEN_CONTROL_CONNECTION =
"com.facebook.portal.smartcamera.external.control.ISmartCameraControlConnection"
private const val TOKEN_CONTROL_SESSION =
"com.facebook.portal.smartcamera.external.control.IControlSession"
private const val TOKEN_METADATA_SERVICE =
"com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataService"
private const val TOKEN_METADATA_CONNECTION =
"com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection"
private const val TX_CONNECT = 2
private const val TX_REQUEST_CONTROLS = 2
private const val TX_SET_MODE = 3
private const val TX_GET_MODE = 2
private const val TX_SUBSCRIBE_MODE = 3
private const val TX_SUBSCRIBE_FRAME_METADATA = 6
private const val REBIND_DELAY_MS = 1_000L
}
private val _state = MutableStateFlow(CameraState())
val state: StateFlow<CameraState> = _state.asStateFlow()
/** Per-mode sub-controllers. */
val auto = AutoModeController(this)
val desk = DeskModeController(this)
val meeting = MeetingModeController(this)
val fixed = FixedModeController(this)
private val _control = MutableStateFlow(ControlSnapshot())
/**
* Combined mode + Fixed crop + Desk tuning. Prefer this for UI / SSE:
* it updates on AIDL mode changes and on local param writes.
*/
val control: StateFlow<ControlSnapshot> = _control.asStateFlow()
private var controlJob: Job? = null
private val lock = Any()
@Volatile private var started = false
@Volatile private var trackCrop = true
@Volatile private var cropRateHz = 1f
@Volatile private var rebindScheduled = false
private var controlService: IBinder? = null
private var controlConnection: IBinder? = null
private var session: IBinder? = null
private var metaService: IBinder? = null
private var metaConnection: IBinder? = null
// Death tokens handed to the service. MUST stay strongly referenced.
private val controlToken = Binder()
private val metaToken = Binder()
private val sessionCallbackToken = Binder()
private val modeListener = ModeListenerBinder { mode ->
_state.update { it.copy(mode = mode) }
}
private val cropReceiver = MetadataReceiverBinder { b ->
@Suppress("DEPRECATION")
(b.get(TOPIC_CROP) as? RectF)?.let { c -> _state.update { s -> s.copy(cropWindow = c) } }
}
private val controlDeathRecipient = IBinder.DeathRecipient {
Log.w(TAG, "control service died")
invalidateControl()
scheduleRebind()
}
private val metaDeathRecipient = IBinder.DeathRecipient {
Log.w(TAG, "metadata service died")
invalidateMeta()
scheduleRebind()
}
private val controlServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
scope.launch(Dispatchers.IO) { setupControl(service) }
}
override fun onServiceDisconnected(name: ComponentName) {
invalidateControl()
scheduleRebind()
}
}
private val metadataServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
scope.launch(Dispatchers.IO) { setupMetadata(service) }
}
override fun onServiceDisconnected(name: ComponentName) {
invalidateMeta()
scheduleRebind()
}
}
/**
* Binds to both services and starts tracking state. Safe to call once;
* subsequent calls are ignored. Reconnects automatically if the service dies.
*
* @param trackCrop subscribe to the "crop" frame-metadata topic so
* [CameraState.cropWindow] stays up to date.
*/
fun start(trackCrop: Boolean = true, cropRateHz: Float = 1f) {
synchronized(lock) {
if (started) return
started = true
this.trackCrop = trackCrop
this.cropRateHz = cropRateHz
}
_state.update { it.copy(connection = ConnectionState.CONNECTING) }
controlJob?.cancel()
controlJob = scope.launch {
combine(_state, fixed.crop, desk.tuning) { st, crop, tuning ->
ControlSnapshot(
connection = st.connection,
mode = st.mode,
fixedCrop = crop,
deskTuning = tuning,
appliedCrop = st.cropWindow,
)
}
.distinctUntilChanged()
.collect { _control.value = it }
}
bind(CONTROL_ACTION, controlServiceConnection)
bind(METADATA_ACTION, metadataServiceConnection)
}
/** Unbinds everything and resets state. */
fun stop() {
controlJob?.cancel()
controlJob = null
synchronized(lock) {
started = false
controlService = null
controlConnection = null
session = null
metaService = null
metaConnection = null
}
runCatching { context.unbindService(controlServiceConnection) }
runCatching { context.unbindService(metadataServiceConnection) }
_state.value = CameraState()
_control.value = ControlSnapshot()
}
/** One-shot query of the current mode; also refreshes [state]. */
suspend fun refreshMode(): String? = withContext(Dispatchers.IO) {
val conn = synchronized(lock) { metaConnection } ?: return@withContext null
try {
val r = Rpc.call(conn, TX_GET_MODE, TOKEN_METADATA_CONNECTION)
val mode = if (r.readInt() != 0) r.readString() else null
r.recycle()
_state.update { it.copy(mode = mode) }
mode
} catch (t: Throwable) {
Log.w(TAG, "getMode failed", t)
null
}
}
/** Sends setMode, rebuilding the session once on failure. */
internal suspend fun setMode(name: String, params: Bundle = Bundle()): Boolean =
withContext(Dispatchers.IO) {
repeat(2) { attempt ->
val s = ensureSession()
if (s == null) {
if (attempt == 0) delay(REBIND_DELAY_MS) // binding may be in flight
return@repeat
}
try {
val r = Rpc.call(s, TX_SET_MODE, TOKEN_CONTROL_SESSION) {
writeInt(1)
writeString(name)
writeBundle(params)
}
r.recycle()
// Optimistic mode so control/UI update before AIDL callback.
_state.update { it.copy(mode = name) }
return@withContext true
} catch (t: Throwable) {
Log.w(TAG, "setMode($name) failed (attempt ${attempt + 1})", t)
synchronized(lock) { session = null }
}
}
false
}
// ---- connection management (all called on Dispatchers.IO) ----
private fun bind(action: String, conn: ServiceConnection) {
val intent = Intent(action).setPackage(AISERVICE_PACKAGE)
try {
if (!context.bindService(intent, conn, Context.BIND_AUTO_CREATE)) {
Log.w(TAG, "$action not found")
}
} catch (t: Throwable) {
Log.w(TAG, "bind $action failed", t)
}
}
private fun setupControl(service: IBinder) {
try {
service.linkToDeath(controlDeathRecipient, 0)
val sess = connectSession(service)
synchronized(lock) {
controlService = service
session = sess
}
_state.update { it.copy(connection = ConnectionState.READY) }
Log.i(TAG, "control session ready")
} catch (t: Throwable) {
Log.w(TAG, "control setup failed", t)
invalidateControl()
scheduleRebind()
}
}
private fun setupMetadata(service: IBinder) {
try {
service.linkToDeath(metaDeathRecipient, 0)
val connReply = Rpc.call(service, TX_CONNECT, TOKEN_METADATA_SERVICE) {
writeStrongBinder(metaToken)
}
val connection = connReply.readStrongBinder()
connReply.recycle()
if (connection == null) throw SmartCameraException("no metadata connection returned")
synchronized(lock) {
metaService = service
metaConnection = connection
}
subscribeMode(connection)
if (trackCrop) subscribeCrop(connection)
Log.i(TAG, "metadata connection ready")
} catch (t: Throwable) {
Log.w(TAG, "metadata setup failed", t)
invalidateMeta()
scheduleRebind()
}
}
private fun connectSession(service: IBinder): IBinder {
val connReply = Rpc.call(service, TX_CONNECT, TOKEN_CONTROL_SERVICE) {
writeStrongBinder(controlToken)
}
val connection = connReply.readStrongBinder()
connReply.recycle()
if (connection == null) throw SmartCameraException("no control connection returned")
val sessReply = Rpc.call(connection, TX_REQUEST_CONTROLS, TOKEN_CONTROL_CONNECTION) {
writeStrongBinder(sessionCallbackToken)
}
val sess = sessReply.readStrongBinder()
sessReply.recycle()
if (sess == null) throw SmartCameraException("no control session returned")
synchronized(lock) { controlConnection = connection }
return sess
}
private fun ensureSession(): IBinder? {
synchronized(lock) { session }?.let { return it }
val service = synchronized(lock) { controlService } ?: return null
return try {
connectSession(service).also { s -> synchronized(lock) { session = s } }
} catch (t: Throwable) {
Log.w(TAG, "session rebuild failed", t)
invalidateControl()
null
}
}
private fun subscribeMode(connection: IBinder) {
val r = Rpc.call(connection, TX_SUBSCRIBE_MODE, TOKEN_METADATA_CONNECTION) {
writeStrongBinder(modeListener)
}
val current = if (r.readInt() != 0) r.readString() else null
r.recycle()
_state.update { it.copy(mode = current) }
}
private fun subscribeCrop(connection: IBinder) {
val r = Rpc.call(connection, TX_SUBSCRIBE_FRAME_METADATA, TOKEN_METADATA_CONNECTION) {
writeStrongBinder(cropReceiver)
writeStringList(listOf(TOPIC_CROP))
writeFloat(cropRateHz)
}
if (r.readInt() != 0) {
@Suppress("DEPRECATION")
(r.readBundle(javaClass.classLoader)?.get(TOPIC_CROP) as? RectF)?.let { c ->
_state.update { s -> s.copy(cropWindow = c) }
}
}
r.recycle()
}
private fun invalidateControl() {
synchronized(lock) {
controlService = null
controlConnection = null
session = null
}
_state.update { it.copy(connection = if (started) ConnectionState.CONNECTING else ConnectionState.DISCONNECTED) }
}
private fun invalidateMeta() {
synchronized(lock) {
metaService = null
metaConnection = null
}
}
private fun scheduleRebind() {
if (!started || rebindScheduled) return
rebindScheduled = true
scope.launch {
delay(REBIND_DELAY_MS)
rebindScheduled = false
if (started) {
if (synchronized(lock) { controlService == null }) bind(CONTROL_ACTION, controlServiceConnection)
if (synchronized(lock) { metaService == null }) bind(METADATA_ACTION, metadataServiceConnection)
}
}
}
}
@@ -0,0 +1,75 @@
package com.portaltv.smartcamera.internal
import android.os.Binder
import android.os.Bundle
import android.os.IBinder
import android.os.Parcel
import android.util.Log
import com.portaltv.smartcamera.SmartCameraException
/** Raw AIDL helpers for the Smart Camera external interfaces (no generated stubs). */
internal object Rpc {
/**
* Runs a transact with interface [token] and returns the reply Parcel.
* The caller must recycle the reply. Throws on rejection or remote exception.
*/
fun call(binder: IBinder, code: Int, token: String, write: Parcel.() -> Unit = {}): Parcel {
val q = Parcel.obtain()
val r = Parcel.obtain()
try {
q.writeInterfaceToken(token)
q.write()
if (!binder.transact(code, q, r, 0)) {
r.recycle()
throw SmartCameraException("transact code=$code on $token rejected")
}
r.readException()
return r
} finally {
q.recycle()
}
}
}
/**
* Stub for IModeListener: onModeChanged(ModeSetting), where ModeSetting parcels
* as a plain String (the full "ModeSetting_<name>").
*/
internal class ModeListenerBinder(private val callback: (String?) -> Unit) : Binder() {
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
if (code != 1) {
return try { super.onTransact(code, data, reply, flags) } catch (t: Throwable) { false }
}
try {
data.readInt() // strict-mode header from writeInterfaceToken
data.readString() // interface descriptor
callback(if (data.readInt() != 0) data.readString() else null)
} catch (t: Throwable) {
Log.w("SmartCamera", "mode listener parse error", t)
}
return true
}
}
/**
* Stub for IStreamingMetadataReceiver: onMetadata(MetadataBundle), where
* MetadataBundle parcels as a Bundle keyed by topic name.
*/
internal class MetadataReceiverBinder(private val callback: (Bundle) -> Unit) : Binder() {
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
if (code != 1) {
return try { super.onTransact(code, data, reply, flags) } catch (t: Throwable) { false }
}
try {
data.readInt() // strict-mode header
data.readString() // interface descriptor
if (data.readInt() != 0) {
data.readBundle(javaClass.classLoader)?.let(callback)
}
} catch (t: Throwable) {
Log.w("SmartCamera", "metadata receiver parse error", t)
}
return true
}
}
@@ -0,0 +1,168 @@
package com.portaltv.capability;
import android.Manifest;
import android.app.*;
import android.os.*;
import android.content.*;
import android.content.pm.PackageManager;
import android.graphics.ImageFormat;
import android.graphics.BitmapFactory;
import android.hardware.camera2.*;
import android.hardware.camera2.params.StreamConfigurationMap;
import android.media.*;
import android.util.Size;
import android.view.*;
import android.widget.*;
import java.util.*;
import java.nio.ByteBuffer;
import java.util.concurrent.*;
public class MainActivity extends Activity {
TextView authStatus;
LinearLayout authPanel;
@Override protected void onResume(){ super.onResume(); PortalStreamingService.activityVisible=true; refreshAuthUi(); Intent i=new Intent(this,PortalStreamingService.class); if(Build.VERSION.SDK_INT>=26) startForegroundService(i); else startService(i); }
@Override protected void onPause(){ PortalStreamingService.activityVisible=false; super.onPause(); }
@Override protected void onStop(){ Intent i=new Intent(this,PortalStreamingService.class); i.setAction("com.portaltv.capability.CLOSE_CLIENTS"); if(Build.VERSION.SDK_INT>=26) startForegroundService(i); else startService(i); super.onStop(); }
TextView log; CameraManager cm; HandlerThread ht; Handler h; CameraDevice cam; ImageReader reader; ImageView preview; IBinder control; IBinder session; IBinder controlToken; IBinder meta; IBinder metaConn; IBinder metaToken; byte[] frameBuf; final java.util.concurrent.atomic.AtomicBoolean frameBusy=new java.util.concurrent.atomic.AtomicBoolean(); int frameCount; float fx=0.5f, fy=0.5f, fs=1.0f;
int screenW,screenH,ctlW=120,logHeaderH=96; boolean controlsExpanded,logExpanded; LinearLayout controlsPanel,controlsContent,logPanel,subFixed,subDesk; TextView subNone; ScrollView logScroll; Button controlsToggle,logToggle; final java.util.Map<String,Button> modeButtons=new java.util.HashMap<>(); String currentMode;
final PortalSmartCamera.StateListener cameraStateListener=state->{
final String mode="ModeSetting_"+state.getMode();
final org.json.JSONObject cfg=state.getConfig();
runOnUiThread(()->{
setCurrentMode(mode);
if("Fixed".equals(state.getMode())&&cfg!=null){
try{
if(cfg.has("centerX")) fx=(float)cfg.getDouble("centerX");
if(cfg.has("centerY")) fy=(float)cfg.getDouble("centerY");
if(cfg.has("scale")) fs=(float)cfg.getDouble("scale");
}catch(Exception e){p("state config parse: "+e);}
}
p("Camera state -> "+state.getMode()+" "+cfg);
});
};
final Binder modeListener=new Binder(){ @Override protected boolean onTransact(int code,Parcel data,Parcel reply,int flags){ if(code==1){ try{ data.readInt(); data.readString(); final String m=data.readInt()!=0?data.readString():null; p("Mode changed (legacy) -> "+(m!=null?m:"(null)")); }catch(Exception e){p("Mode listener parse error: "+e);} return true; } try{return super.onTransact(code,data,reply,flags);}catch(Exception e){return false;} } };
final Binder metaReceiver=new Binder(){ @Override protected boolean onTransact(int code,Parcel data,Parcel reply,int flags){ if(code==1){ try{ data.readInt(); data.readString(); if(data.readInt()!=0){ Bundle b=data.readBundle(getClass().getClassLoader()); String s=""; for(String k:b.keySet()) s+=k+"="+b.get(k)+" "; p("meta: "+s); } }catch(Exception e){p("Meta receiver parse error: "+e);} return true; } try{return super.onTransact(code,data,reply,flags);}catch(Exception e){return false;} } };
public void onCreate(Bundle b) { super.onCreate(b); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); buildUi();
PortalSmartCamera.start(this);
PortalSmartCamera.addStateListener(cameraStateListener);
if (Build.VERSION.SDK_INT >= 23 && (checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED || checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED)) requestPermissions(new String[]{Manifest.permission.CAMERA,Manifest.permission.RECORD_AUDIO},7);
else startAll();
cm=(CameraManager)getSystemService(CAMERA_SERVICE); ht=new HandlerThread("camera"); ht.start(); h=new Handler(ht.getLooper()); p("Running as uid="+android.os.Process.myUid()); inspect();
Intent service = new Intent(this, PortalStreamingService.class);
if (Build.VERSION.SDK_INT >= 26) startForegroundService(service); else startService(service);
}
volatile boolean pipelineStarted;
synchronized void ensurePipeline(){ if(pipelineStarted)return; pipelineStarted=true; new Thread(()->{ testCamera("0"); startMicLoop(); startAudioEncoder(); startTsMuxer(); }).start(); p("Media pipeline started on first client"); }
void startAll(){ p("Camera UI driven by PortalSmartCamera state events"); }
void startVideoEncoder(){ if(venc!=null)return; try{ MediaFormat f=MediaFormat.createVideoFormat("video/avc",1280,720); f.setInteger(MediaFormat.KEY_COLOR_FORMAT,MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); f.setInteger(MediaFormat.KEY_BIT_RATE,2500000); f.setInteger(MediaFormat.KEY_FRAME_RATE,30); f.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL,1); venc=MediaCodec.createEncoderByType("video/avc"); venc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); vencSurface=venc.createInputSurface(); venc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); while(true){ int i; try{ i=venc.dequeueOutputBuffer(bi,100000); }catch(Exception e){ p("venc drained: "+e); return; } if(i==MediaCodec.INFO_OUTPUT_FORMAT_CHANGED){ try{ java.io.ByteArrayOutputStream c=new java.io.ByteArrayOutputStream(); MediaFormat of=venc.getOutputFormat(); for(String k:new String[]{"csd-0","csd-1"}) if(of.containsKey(k)){ java.nio.ByteBuffer cb=of.getByteBuffer(k); byte[] x=new byte[cb.remaining()]; cb.get(x); c.write(x); } vCsd=c.toByteArray(); p("H.264 encoder ready, csd="+vCsd.length+"B"); }catch(Exception e){p("csd parse: "+e);} continue; } if(i<0) continue; java.nio.ByteBuffer buf=venc.getOutputBuffer(i); if(buf!=null&&bi.size>0){ byte[] d=new byte[bi.size]; buf.position(bi.offset); buf.get(d); boolean key=(bi.flags&(MediaCodec.BUFFER_FLAG_KEY_FRAME|MediaCodec.BUFFER_FLAG_CODEC_CONFIG))!=0; if((bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)!=0) vCsd=d; VChunk ch=new VChunk(d,key,bi.presentationTimeUs); tsV.offer(ch); synchronized(vClients){ java.util.Iterator<java.util.concurrent.BlockingQueue<VChunk>> it=vClients.iterator(); while(it.hasNext()){ if(!it.next().offer(ch)) it.remove(); } } } venc.releaseOutputBuffer(i,false); } }).start(); p("H.264 encoder started (720p30 @2.5Mbps)"); }catch(Exception e){p("H.264 encoder failed: "+e); venc=null; vencSurface=null;} }
void startAudioEncoder(){ if(aenc!=null)return; try{ MediaFormat f=MediaFormat.createAudioFormat("audio/mp4a-latm",48000,1); f.setInteger(MediaFormat.KEY_AAC_PROFILE,MediaCodecInfo.CodecProfileLevel.AACObjectLC); f.setInteger(MediaFormat.KEY_BIT_RATE,64000); aenc=MediaCodec.createEncoderByType("audio/mp4a-latm"); aenc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); aenc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); long aT0=-1,aSamples=0; byte[] pending=null; int pOff=0; long bIn=0,fOut=0,tOut=0,tWin=System.nanoTime(); while(true){ try{ if(pending==null){ pending=pcmIn.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS); pOff=0; } if(pending!=null){ int ii=aenc.dequeueInputBuffer(50000); if(ii>=0){ java.nio.ByteBuffer ib=aenc.getInputBuffer(ii); ib.clear(); int put=Math.min(pending.length-pOff,ib.remaining()); ib.put(pending,pOff,put); if(aT0<0) aT0=System.nanoTime(); long pts=(aT0+aSamples*1000000000L/48000)/1000; aSamples+=put/2; aenc.queueInputBuffer(ii,0,put,pts,0); bIn+=put; pOff+=put; if(pOff>=pending.length) pending=null; } else tOut++; } int i=aenc.dequeueOutputBuffer(bi,pending==null?20000:0); while(i>=0){ java.nio.ByteBuffer buf=aenc.getOutputBuffer(i); if(buf!=null&&bi.size>0&&(bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)==0){ byte[] raw=new byte[bi.size]; buf.position(bi.offset); buf.get(raw); byte[] adts=addAdts(raw); tsA.offer(new VChunk(adts,false,bi.presentationTimeUs)); synchronized(aClients){ for(java.util.concurrent.BlockingQueue<byte[]> q:aClients) q.offer(adts); } fOut++; } aenc.releaseOutputBuffer(i,false); i=aenc.dequeueOutputBuffer(bi,0); } long now=System.nanoTime(); if(now-tWin>5e9){ p(String.format("aenc: %.0f B/s in, %.1f frames/s out, inTimeouts=%d, queue=%d",bIn*1e9/(now-tWin),fOut*1e9/(now-tWin),tOut,pcmIn.size())); tWin=now; bIn=0; fOut=0; tOut=0; } }catch(Exception e){ p("aenc drained: "+e); return; } } }).start(); p("AAC encoder started (48kHz mono @64kbps)"); }catch(Exception e){p("AAC encoder failed: "+e); aenc=null;} }
byte[] addAdts(byte[] f){ int len=f.length+7; byte[] o=new byte[len]; o[0]=(byte)0xFF; o[1]=(byte)0xF1; o[2]=(byte)((1<<6)|(3<<2)); o[3]=(byte)((1<<6)|(len>>11)); o[4]=(byte)((len>>3)&0xFF); o[5]=(byte)(((len&7)<<5)|0x1F); o[6]=(byte)0xFC; System.arraycopy(f,0,o,7,f.length); return o; }
String deviceIp(){ try{ for(java.net.NetworkInterface ni:java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) for(java.net.InetAddress a:java.util.Collections.list(ni.getInetAddresses())) if(!a.isLoopbackAddress()&&a instanceof java.net.Inet4Address) return a.getHostAddress(); }catch(Exception e){} return "?"; }
// ---- minimal MPEG-TS muxer (H.264 Annex B + AAC ADTS, PTS from the encoders) ----
static int crcMpeg(byte[] d,int off,int len){ int c=0xFFFFFFFF; for(int i=off;i<off+len;i++){ c^=(d[i]&0xFF)<<24; for(int b=0;b<8;b++) c=(c&0x80000000)!=0?(c<<1)^0x04C11DB7:c<<1; } return c; }
byte[] patPacket(){ byte[] s={0x00,(byte)0xB0,0x0D,0x00,0x01,(byte)0xC1,0x00,0x00,0x00,0x01,(byte)0xF0,0x00}; return tablePacket(0,s); }
byte[] pmtPacket(){ byte[] s={0x02,(byte)0xB0,0x17,0x00,0x01,(byte)0xC1,0x00,0x00,(byte)0xE1,0x01,(byte)0xF0,0x00,0x1B,(byte)0xE1,0x01,(byte)0xF0,0x00,0x0F,(byte)0xE1,0x02,(byte)0xF0,0x00}; return tablePacket(0x1000,s); }
byte[] tablePacket(int pid,byte[] sec){ int crc=crcMpeg(sec,0,sec.length); byte[] full=new byte[sec.length+5]; full[0]=0; System.arraycopy(sec,0,full,1,sec.length); int n=sec.length+1; full[n++]=(byte)(crc>>24); full[n++]=(byte)(crc>>16); full[n++]=(byte)(crc>>8); full[n]=(byte)crc; java.util.List<byte[]> pk=packetize(pid,false,full,0); return pk.get(0); }
void startTsMuxer(){ new Thread(()->{ while(true){ try{ int na=0; VChunk a; while(na++<10){ a=tsA.poll(); if(a==null) break; writePes(0x102,0xE1,a,false); } VChunk v=tsV.poll(200,java.util.concurrent.TimeUnit.MILLISECONDS); if(v==null) continue; if(vBase<0) vBase=v.pts; if(v.k){ tsBroadcast(patPacket()); tsBroadcast(pmtPacket()); if(vCsd!=null&&!hasSps(v.d)){ byte[] m=new byte[vCsd.length+v.d.length]; System.arraycopy(vCsd,0,m,0,vCsd.length); System.arraycopy(v.d,0,m,vCsd.length,v.d.length); v=new VChunk(m,true,v.pts); } } writePes(0x101,0xE0,v,true); }catch(Exception e){ p("ts mux: "+e); } } }).start(); }
static boolean hasSps(byte[] d){ for(int i=0;i+4<Math.min(d.length,64);i++){ if(d[i]==0&&d[i+1]==0&&d[i+2]==1&&(d[i+3]&0x1F)==7) return true; if(d[i]==0&&d[i+1]==0&&d[i+2]==0&&d[i+3]==1&&(d[i+4]&0x1F)==7) return true; } return false; }
void tsBroadcast(byte[] d){ synchronized(tsClients){ java.util.Iterator<java.util.concurrent.BlockingQueue<byte[]>> it=tsClients.iterator(); while(it.hasNext()){ if(!it.next().offer(d)) it.remove(); } } }
void writePes(int pid,int sid,VChunk c,boolean isVideo){ if(tsClients.isEmpty()) return; long base=isVideo?vBase:aBase; if(base<0){ if(isVideo) vBase=c.pts; else aBase=c.pts; base=c.pts; } long pts=(c.pts-base)*9/100; java.io.ByteArrayOutputStream pes=new java.io.ByteArrayOutputStream(); pes.write(0); pes.write(0); pes.write(1); pes.write(sid); int pl=isVideo?0:c.d.length+8; pes.write(pl>>8); pes.write(pl); pes.write(0x80); pes.write(0x80); pes.write(5); pes.write((2<<4)|((int)((pts>>30)&7)<<1)|1); pes.write((int)(pts>>22)&0xFF); pes.write((int)(((pts>>15)&0x7F)<<1)|1); pes.write((int)(pts>>7)&0xFF); pes.write((int)((pts&0x7F)<<1)|1); pes.write(c.d,0,c.d.length); for(byte[] p:packetize(pid,isVideo,pes.toByteArray(),pts)) tsBroadcast(p); }
java.util.List<byte[]> packetize(int pid,boolean isVideo,byte[] pes,long pcr90k){ java.util.List<byte[]> out=new java.util.ArrayList<>(); int off=0; boolean first=true; while(off<pes.length){ byte[] p=new byte[188]; p[0]=0x47; p[1]=(byte)((first?0x40:0)|((pid>>8)&0x1F)); p[2]=(byte)pid; boolean pcr=first&&isVideo; int room=184-(pcr?8:0); int remain=pes.length-off; int take=Math.min(remain,room); boolean stuff=take<room; int afc=(pcr||stuff)?3:1; int cc=pid==0x101?ccV++&15:pid==0x102?ccA++&15:pid==0?ccPAT++&15:ccPMT++&15; p[3]=(byte)((afc<<4)|cc); int pos=4; if(afc==3){ int afLen=183-take; p[pos++]= (byte)afLen; p[pos++]=(byte)(pcr?0x10:0); if(pcr){ long b=pcr90k; p[pos++]=(byte)(b>>25); p[pos++]=(byte)(b>>17); p[pos++]=(byte)(b>>9); p[pos++]=(byte)(b>>1); p[pos++]=(byte)((b<<7)|0x7E); p[pos++]=0; } while(pos<4+1+afLen) p[pos++]=(byte)0xFF; } System.arraycopy(pes,off,p,pos,take); off+=take; first=false; out.add(p); } return out; }
// ---- end TS muxer ----
void startWebcamServer(){ if(httpSock!=null)return; new Thread(()->{ try{ httpSock=new java.net.ServerSocket(5654); p("Webcam server: http://"+deviceIp()+":5654/ (video=/video.h264 audio=/audio.aac)"); while(true){ final java.net.Socket s=httpSock.accept(); new Thread(()->handleHttp(s)).start(); } }catch(Exception e){p("Webcam server failed: "+e);} }).start(); }
void handleHttp(java.net.Socket s){ try{ s.setTcpNoDelay(true); java.io.BufferedReader in=new java.io.BufferedReader(new java.io.InputStreamReader(s.getInputStream())); String line=in.readLine(); if(line==null){s.close();return;} String path=line.split(" ")[1]; while((line=in.readLine())!=null&&!line.isEmpty()){} java.io.OutputStream out=s.getOutputStream();
if(path.equals("/")||path.startsWith("/index")){ String h="<html><body style='background:#111;color:#eee;font-family:monospace'><h3>Portal webcam</h3><b><a style='color:#8af' href='/stream.ts'>/stream.ts</a> (MPEG-TS: H.264 720p30 + AAC 48kHz mono, synced)</b><br>video: <a style='color:#8af' href='/video.h264'>/video.h264</a> (raw H.264 Annex B)<br>audio: <a style='color:#8af' href='/audio.aac'>/audio.aac</a> (raw AAC ADTS)<br><br>ffplay http://"+deviceIp()+":5654/stream.ts<br>mpv http://"+deviceIp()+":5654/stream.ts<br>vlc http://"+deviceIp()+":5654/stream.ts<br></body></html>"; byte[] b=h.getBytes(); out.write(("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: "+b.length+"\r\nConnection: close\r\n\r\n").getBytes()); out.write(b); out.flush(); s.close(); return; }
if(path.startsWith("/video.h264")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: video/h264\r\nCache-Control: no-store, no-cache, must-revalidate\r\nPragma: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n".getBytes()); out.flush(); byte[] csd=vCsd; if(csd!=null){ out.write(csd); out.flush(); } if(venc!=null) try{ Bundle pb=new Bundle(); pb.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME,0); venc.setParameters(pb); }catch(Exception e){} java.util.concurrent.BlockingQueue<VChunk> q=new java.util.concurrent.ArrayBlockingQueue<>(3); vClients.add(q); boolean started=false; try{ while(true){ VChunk c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!vClients.contains(q)) break; continue; } if(!started){ if(!c.k) continue; started=true; } out.write(c.d); out.flush(); } }finally{ vClients.remove(q); } return; }
if(path.startsWith("/stream.ts")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nCache-Control: no-store, no-cache, must-revalidate\r\nPragma: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n".getBytes()); out.flush(); if(venc!=null) try{ Bundle pb=new Bundle(); pb.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME,0); venc.setParameters(pb); }catch(Exception e){} java.util.concurrent.BlockingQueue<byte[]> q=new java.util.concurrent.ArrayBlockingQueue<>(24); synchronized(tsClients){ if(tsClients.isEmpty()){ vBase=-1; aBase=-1; } tsClients.add(q); } try{ while(true){ byte[] c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!tsClients.contains(q)) break; continue; } out.write(c); out.flush(); } }finally{ tsClients.remove(q); } return; }
if(path.startsWith("/audio.aac")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: audio/aac\r\nCache-Control: no-store\r\n\r\n".getBytes()); out.flush(); java.util.concurrent.BlockingQueue<byte[]> q=new java.util.concurrent.ArrayBlockingQueue<>(256); aClients.add(q); try{ while(true){ byte[] c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!aClients.contains(q)) break; continue; } out.write(c); out.flush(); } }finally{ aClients.remove(q); } return; }
if(path.startsWith("/control/mode")){ String mode=query(path,"mode"); if(mode==null||!mode.matches("DefaultAuto|Desk|Meeting|Fixed")){ reply(out,400,"mode must be DefaultAuto, Desk, Meeting, or Fixed"); return; } setSmartMode(mode); reply(out,200,"mode requested: "+mode); return; }
if(path.startsWith("/control/fixed")){ try{ fx=Float.parseFloat(query(path,"x")); fy=Float.parseFloat(query(path,"y")); fs=Float.parseFloat(query(path,"scale")); setSmartMode("Fixed"); reply(out,200,String.format("fixed requested: x=%.3f y=%.3f scale=%.3f",fx,fy,fs)); }catch(Exception e){ reply(out,400,"x, y, and scale are required numbers"); } return; }
out.write("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".getBytes()); out.flush(); s.close();
}catch(Exception e){} try{ s.close(); }catch(Exception e){} }
String query(String path,String key){ int q=path.indexOf('?'); if(q<0)return null; for(String p:path.substring(q+1).split("&")){String[] kv=p.split("=",2);if(kv.length==2&&java.net.URLDecoder.decode(kv[0]).equals(key))return java.net.URLDecoder.decode(kv[1]);}return null; }
void reply(java.io.OutputStream out,int code,String body)throws java.io.IOException{byte[] b=body.getBytes();out.write(("HTTP/1.1 "+code+" OK\r\nContent-Type: text/plain\r\nContent-Length: "+b.length+"\r\nConnection: close\r\n\r\n").getBytes());out.write(b);out.flush();}
public void onRequestPermissionsResult(int rc,String[] p,int[] r){ startAll(); }
void buildUi(){
screenW=getResources().getDisplayMetrics().widthPixels; screenH=getResources().getDisplayMetrics().heightPixels;
LinearLayout root=new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL); root.setBackgroundColor(0xff101010);
authStatus=new TextView(this); authStatus.setTextColor(0xffffffff); authStatus.setPadding(12,8,12,8); root.addView(authStatus,new LinearLayout.LayoutParams(-1,70));
authPanel=new LinearLayout(this); authPanel.setOrientation(LinearLayout.VERTICAL); root.addView(authPanel,new LinearLayout.LayoutParams(-1,110));
Button revokeAll=new Button(this); revokeAll.setText("Revoke all paired clients"); revokeAll.setOnClickListener(v->{ Intent x=new Intent(this,PortalStreamingService.class); x.setAction("com.portaltv.capability.REVOKE_ALL"); if(Build.VERSION.SDK_INT>=26) startForegroundService(x); else startService(x); }); root.addView(revokeAll,new LinearLayout.LayoutParams(-1,70));
LinearLayout main=new LinearLayout(this); main.setOrientation(LinearLayout.HORIZONTAL); root.addView(main,new LinearLayout.LayoutParams(-1,0,1));
preview=new ImageView(this); preview.setBackgroundColor(0xff202020); preview.setScaleType(ImageView.ScaleType.FIT_CENTER); main.addView(preview,new LinearLayout.LayoutParams(0,-1,1));
controlsPanel=new LinearLayout(this); controlsPanel.setOrientation(LinearLayout.VERTICAL); controlsPanel.setBackgroundColor(0xff303030); controlsPanel.setPadding(8,8,8,8); main.addView(controlsPanel,new LinearLayout.LayoutParams(ctlW,-1));
LinearLayout trow=new LinearLayout(this); controlsToggle=new Button(this); controlsToggle.setText("<"); focusFx(controlsToggle); trow.addView(controlsToggle,new LinearLayout.LayoutParams(-2,84)); controlsPanel.addView(trow); controlsToggle.setOnClickListener(v->toggleControls());
ScrollView cs=new ScrollView(this); controlsContent=new LinearLayout(this); controlsContent.setOrientation(LinearLayout.VERTICAL); controlsContent.setVisibility(View.GONE); cs.addView(controlsContent); controlsPanel.addView(cs,new LinearLayout.LayoutParams(-1,0,1));
TextView ml=new TextView(this); ml.setText("Mode"); ml.setTextColor(0xffffffff); controlsContent.addView(ml);
for(final String mode:new String[]{"DefaultAuto","Desk","Meeting","Fixed"}){ Button x=new Button(this); x.setText(mode); x.setTextColor(0xffffffff); x.setBackgroundColor(C_IDLE); x.setOnClickListener(v->setSmartMode(mode)); x.setOnFocusChangeListener((v,f)->styleModeButton((Button)v,mode)); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; controlsContent.addView(x,lp); modeButtons.put(mode,x); }
FrameLayout subHost=new FrameLayout(this); LinearLayout.LayoutParams slp=new LinearLayout.LayoutParams(-1,-2); slp.topMargin=12; controlsContent.addView(subHost,slp);
subFixed=new LinearLayout(this); subFixed.setOrientation(LinearLayout.VERTICAL); subFixed.setVisibility(View.GONE);
LinearLayout fr1=new LinearLayout(this); LinearLayout fr2=new LinearLayout(this);
String[][] pan={{"<","-0.05","0","1"},{">","0.05","0","1"},{"^","0","-0.05","1"},{"v","0","0.05","1"}}; for(final String[] t:pan){ Button x=new Button(this); x.setText(t[0]); x.setOnClickListener(v->nudgeFixed(Float.parseFloat(t[1]),Float.parseFloat(t[2]),Float.parseFloat(t[3]))); focusFx(x); fr1.addView(x,new LinearLayout.LayoutParams(0,84,1)); }
String[][] zm={{"Z+","0","0","0.85"},{"Z-","0","0","1.1765"}}; for(final String[] t:zm){ Button x=new Button(this); x.setText(t[0]); x.setOnClickListener(v->nudgeFixed(Float.parseFloat(t[1]),Float.parseFloat(t[2]),Float.parseFloat(t[3]))); focusFx(x); fr2.addView(x,new LinearLayout.LayoutParams(0,84,1)); }
subFixed.addView(fr1); subFixed.addView(fr2); subHost.addView(subFixed);
subDesk=new LinearLayout(this); subDesk.setOrientation(LinearLayout.VERTICAL); subDesk.setVisibility(View.GONE);
for(final float t:new float[]{0.0f,0.5f,1.0f}){ Button x=new Button(this); x.setText("tight "+t); x.setOnClickListener(v->{Bundle b=new Bundle(); b.putFloat("additional_stable_framing_tightness",t); sendMode("ModeSetting_Desk",b,"Desk tight="+t);}); focusFx(x); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; subDesk.addView(x,lp); }
subHost.addView(subDesk);
subNone=new TextView(this); subNone.setText("No mode parameters"); subNone.setTextColor(0xffaaaaaa); subNone.setVisibility(View.GONE); subHost.addView(subNone);
logPanel=new LinearLayout(this); logPanel.setOrientation(LinearLayout.VERTICAL); logPanel.setBackgroundColor(0xff282828); root.addView(logPanel,new LinearLayout.LayoutParams(-1,logHeaderH));
LinearLayout hdr=new LinearLayout(this); hdr.setGravity(16); TextView lt=new TextView(this); lt.setText("Log"); lt.setTextColor(0xffffffff); hdr.addView(lt,new LinearLayout.LayoutParams(0,-2,1)); logToggle=new Button(this); logToggle.setText("^"); focusFx(logToggle); hdr.addView(logToggle,new LinearLayout.LayoutParams(-2,-2)); logToggle.setOnClickListener(v->toggleLog()); logPanel.addView(hdr);
log=new TextView(this); log.setTextSize(14); log.setTextColor(0xffeeeeee); logScroll=new ScrollView(this); logScroll.addView(log); logScroll.setVisibility(View.GONE); logPanel.addView(logScroll,new LinearLayout.LayoutParams(-1,0,1));
setContentView(root);
}
void refreshAuthUi(){ if(authPanel==null)return; android.content.SharedPreferences p=getSharedPreferences("auth",MODE_PRIVATE); int av=p.getInt("activeVideo",0), aa=p.getInt("activeAudio",0); authStatus.setText((p.getString("pairingPin","").isEmpty()?"HTTPS ready":"Pairing PIN: "+p.getString("pairingPin",""))+" Active video: "+av+" audio: "+aa); authPanel.removeAllViews(); java.util.Set<String> ts=p.getStringSet("tokens",java.util.Collections.emptySet()); for(String h:ts){ String m=p.getString("client."+h,"unknown"); Button b=new Button(this); b.setText("Revoke "+m.replace('|',' ')); b.setOnClickListener(v->{ java.util.Set<String> n=p.getStringSet("tokens",java.util.Collections.emptySet()); n=new java.util.HashSet<>(n); n.remove(h); p.edit().putStringSet("tokens",n).remove("client."+h).apply(); refreshAuthUi(); }); authPanel.addView(b,new LinearLayout.LayoutParams(-1,60)); } }
Button mkBtn(String t,View.OnClickListener l){ Button x=new Button(this); x.setText(t); x.setOnClickListener(l); focusFx(x); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; x.setLayoutParams(lp); return x; }
void toggleControls(){ controlsExpanded=!controlsExpanded; android.view.ViewGroup.LayoutParams lp=controlsPanel.getLayoutParams(); lp.width=controlsExpanded?screenW/5:ctlW; controlsPanel.setLayoutParams(lp); controlsContent.setVisibility(controlsExpanded?View.VISIBLE:View.GONE); controlsToggle.setText(controlsExpanded?">":"<"); }
void toggleLog(){ logExpanded=!logExpanded; android.view.ViewGroup.LayoutParams lp=logPanel.getLayoutParams(); lp.height=logExpanded?screenH/3:logHeaderH; logPanel.setLayoutParams(lp); logScroll.setVisibility(logExpanded?View.VISIBLE:View.GONE); logToggle.setText(logExpanded?"v":"^"); }
static final int C_ACTIVE=0xff2e7d32, C_IDLE=0xff424242, C_FOCUS=0xffff9800;
void styleModeButton(Button b,String mode){ boolean a=("ModeSetting_"+mode).equals(currentMode); if(b.isFocused()){ b.setBackgroundColor(C_FOCUS); b.setTextColor(0xff000000); } else { b.setBackgroundColor(a?C_ACTIVE:C_IDLE); b.setTextColor(0xffffffff); } }
void focusFx(Button b){ final android.graphics.drawable.Drawable d=b.getBackground(); final android.content.res.ColorStateList tc=b.getTextColors(); b.setOnFocusChangeListener((v,f)->{ if(f){ b.setBackgroundColor(C_FOCUS); b.setTextColor(0xff000000); } else { b.setBackground(d); b.setTextColor(tc); } }); }
void setCurrentMode(String m){ currentMode=m; runOnUiThread(()->{ for(java.util.Map.Entry<String,Button> e:modeButtons.entrySet()) styleModeButton(e.getValue(),e.getKey()); subFixed.setVisibility("ModeSetting_Fixed".equals(currentMode)?View.VISIBLE:View.GONE); subDesk.setVisibility("ModeSetting_Desk".equals(currentMode)?View.VISIBLE:View.GONE); subNone.setVisibility(("ModeSetting_DefaultAuto".equals(currentMode)||"ModeSetting_Meeting".equals(currentMode))?View.VISIBLE:View.GONE); }); }
void p(String x){android.util.Log.d("PortalCap",x); runOnUiThread(()->log.append(String.format("%tT ",System.currentTimeMillis())+x+"\n"));}
void inspect(){ try{ for(String id:cm.getCameraIdList()){CameraCharacteristics c=cm.getCameraCharacteristics(id); StreamConfigurationMap map=c.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); p("Camera "+id+": facing="+c.get(CameraCharacteristics.LENS_FACING)+", sensor="+c.get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE)); if(map!=null){Size[] y=map.getOutputSizes(ImageFormat.YUV_420_888); if(y!=null){String z=""; for(Size q:y) if(q.getWidth()>=1280) z+=q+" "; p(" YUV outputs: "+z);} Size[] j=map.getOutputSizes(ImageFormat.JPEG); if(j!=null){String z=""; for(Size q:j) if(q.getWidth()>=1280) z+=q+" "; p(" JPEG outputs: "+z);}} }}catch(Exception e){p("Inspect error: "+e);}}
void testCamera(final String id){
if(checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED){p("Camera permission not granted");return;}
if(cam!=null){cam.close();cam=null;} if(reader!=null){reader.close();reader=null;}
p("Opening camera "+id+" at 3840x2160 YUV...");
try {
reader=ImageReader.newInstance(1280,720,ImageFormat.JPEG,2);
startVideoEncoder();
frameBusy.set(false); frameCount=0;
reader.setOnImageAvailableListener(r->{Image im=r.acquireLatestImage(); if(im==null)return; if(frameBusy.getAndSet(true)){im.close();return;} ByteBuffer bb=im.getPlanes()[0].getBuffer(); int len=bb.remaining(); if(frameBuf==null||frameBuf.length<len)frameBuf=new byte[len]; bb.get(frameBuf,0,len); im.close(); android.graphics.Bitmap bmp=BitmapFactory.decodeByteArray(frameBuf,0,len); runOnUiThread(()->{android.graphics.drawable.Drawable old=preview.getDrawable(); preview.setImageBitmap(bmp); if(old instanceof android.graphics.drawable.BitmapDrawable)((android.graphics.drawable.BitmapDrawable)old).getBitmap().recycle(); frameBusy.set(false);});},h);
cm.openCamera(id,new CameraDevice.StateCallback(){
public void onOpened(CameraDevice c){
cam=c;
try {
CaptureRequest.Builder q=c.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); Surface out=reader.getSurface(); q.addTarget(out); if(vencSurface!=null) q.addTarget(vencSurface);
c.createCaptureSession(vencSurface!=null?Arrays.asList(out,vencSurface):Collections.singletonList(out),new CameraCaptureSession.StateCallback(){
public void onConfigured(CameraCaptureSession s){try{s.setRepeatingRequest(q.build(),null,h);p("Camera "+id+" capture started");}catch(Exception e){p("Capture failed: "+e);}}
public void onConfigureFailed(CameraCaptureSession s){p("Camera "+id+" configuration rejected: "+s);}
},h);
} catch(Exception e){p("Camera "+id+" setup failed: "+e);}
}
public void onDisconnected(CameraDevice c){p("Camera "+id+" disconnected");c.close();}
public void onError(CameraDevice c,int e){p("Camera "+id+" error "+e);c.close();}
},h);
} catch(Exception e){p("Camera "+id+" open failed: "+e);}
}
volatile boolean micLoop; Thread micThread;
static class VChunk{ final byte[] d; final boolean k; final long pts; VChunk(byte[] d,boolean k,long pts){this.d=d;this.k=k;this.pts=pts;} }
final java.util.concurrent.BlockingQueue<VChunk> tsV=new java.util.concurrent.ArrayBlockingQueue<>(120);
final java.util.concurrent.BlockingQueue<VChunk> tsA=new java.util.concurrent.ArrayBlockingQueue<>(256);
final java.util.Set<java.util.concurrent.BlockingQueue<byte[]>> tsClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<byte[]>>());
int ccV,ccA,ccPAT,ccPMT; long vBase=-1,aBase=-1;
final java.util.Set<java.util.concurrent.BlockingQueue<VChunk>> vClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<VChunk>>());
final java.util.Set<java.util.concurrent.BlockingQueue<byte[]>> aClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<byte[]>>());
final java.util.concurrent.BlockingQueue<byte[]> pcmIn=new java.util.concurrent.ArrayBlockingQueue<>(128);
volatile byte[] vCsd; MediaCodec venc,aenc; Surface vencSurface; java.net.ServerSocket httpSock;
void startMicLoop(){ if(checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED){p("Microphone permission not granted");return;} if(micThread!=null)return; micLoop=true; micThread=new Thread(()->{ int n=AudioRecord.getMinBufferSize(48000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT); AudioRecord ar=null; try{ ar=new AudioRecord(MediaRecorder.AudioSource.DEFAULT,48000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT,n*2); ar.startRecording(); p("Mic capture started: actualRate="+ar.getSampleRate()+" minBuf="+n+" state="+ar.getState()); byte[] b=new byte[n]; long tWin=System.nanoTime(),bWin=0; while(micLoop){ int got=ar.read(b,0,b.length); if(got>0){ if(!pcmIn.offer(java.util.Arrays.copyOf(b,got))) p("pcmIn FULL, dropped "+got+"B"); bWin+=got; long now=System.nanoTime(); if(now-tWin>5e9){ p("mic rate: "+(bWin*1e9/(now-tWin))+" B/s (expect 96000), queue="+pcmIn.size()); tWin=now; bWin=0; } } } }catch(Exception e){p("Mic capture failed: "+e);} finally{ try{if(ar!=null){ar.stop();ar.release();}}catch(Exception e){} micThread=null; } }); micThread.start(); }
void sendMode(final String modeName,final Bundle b,final String label){ try{ if(modeName.equals("ModeSetting_Desk")&&b!=null&&b.containsKey("additional_stable_framing_tightness")){ PortalSmartCamera.setDeskTightness(b.getFloat("additional_stable_framing_tightness")); p(label+" sent"); return; } String shortName=modeName.startsWith("ModeSetting_")?modeName.substring("ModeSetting_".length()):modeName; if(shortName.equals("Fixed")) PortalSmartCamera.setMode(shortName,fx,fy,fs); else PortalSmartCamera.setMode(shortName); p(label+" sent"); }catch(Exception e){p(label+" failed: "+e);} }
void setSmartMode(String mode){ if(mode.equals("Fixed")) PortalSmartCamera.setMode(mode,fx,fy,fs); else PortalSmartCamera.setMode(mode); }
void nudgeFixed(float dx,float dy,float sm){ fs=Math.min(1,Math.max(0.1f,fs*sm)); fx=Math.min(1-fs/2,Math.max(fs/2,fx+dx)); fy=Math.min(1-fs/2,Math.max(fs/2,fy+dy)); setSmartMode("Fixed"); }
void ensureSession(final Runnable next){ if(control==null){Intent i=new Intent("com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE");i.setPackage("com.facebook.portal.aiservice");bindService(i,new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){control=b;p("Smart Camera service bound");ensureSession(next);}public void onServiceDisconnected(ComponentName n){control=null;session=null;}},BIND_AUTO_CREATE);p("Binding Smart Camera service...");return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.control.ISmartCameraControlService");controlToken=new Binder();q.writeStrongBinder(controlToken); if(!control.transact(2,q,r,0)){p("Smart Camera connect rejected");return;} r.readException(); IBinder connection=r.readStrongBinder();if(connection==null){p("No control connection returned");return;} Parcel cr=Parcel.obtain(),co=Parcel.obtain();cr.writeInterfaceToken("com.facebook.portal.smartcamera.external.control.ISmartCameraControlConnection");cr.writeStrongBinder(new Binder());if(!connection.transact(2,cr,co,0)){p("requestControls rejected");return;}co.readException();session=co.readStrongBinder();if(session==null){p("No control session returned");return;} p("Control session established"); next.run(); }catch(Exception e){p("Control connect failed: "+e);} }
void ensureMeta(final Runnable next){ if(meta==null){Intent i=new Intent("com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE");i.setPackage("com.facebook.portal.aiservice");bindService(i,new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){meta=b;p("Metadata service bound");ensureMeta(next);}public void onServiceDisconnected(ComponentName n){meta=null;metaConn=null;}},BIND_AUTO_CREATE);p("Binding metadata service...");return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataService");metaToken=new Binder();q.writeStrongBinder(metaToken); if(!meta.transact(2,q,r,0)){p("Metadata connect rejected");return;} r.readException(); metaConn=r.readStrongBinder();if(metaConn==null){p("No metadata connection returned");return;} p("Metadata connection established"); next.run(); }catch(Exception e){p("Metadata connect failed: "+e);} }
void queryMode(){ if(metaConn==null){ensureMeta(()->queryMode());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection"); if(!metaConn.transact(2,q,r,0)){p("getMode call failed");return;} r.readException(); String m=r.readInt()!=0?r.readString():null; p("Current mode: "+(m!=null?m:"(null)")); setCurrentMode(m); }catch(Exception e){p("getMode failed: "+e);} }
void watchModes(){ if(metaConn==null){ensureMeta(()->watchModes());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection");q.writeStrongBinder(modeListener); if(!metaConn.transact(3,q,r,0)){p("subscribeModeChanges failed");return;} r.readException(); String m=r.readInt()!=0?r.readString():null; p("Watching modes; current: "+(m!=null?m:"(null)")); setCurrentMode(m); }catch(Exception e){p("subscribeModeChanges failed: "+e);} }
void watchCrop(){ if(metaConn==null){ensureMeta(()->watchCrop());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection");q.writeStrongBinder(metaReceiver); ArrayList<String> t=new ArrayList<String>(); t.add("crop"); q.writeStringList(t); q.writeFloat(1.0f); if(!metaConn.transact(6,q,r,0)){p("subscribeFrameMetadata failed");return;} r.readException(); if(r.readInt()!=0){ Bundle b=r.readBundle(getClass().getClassLoader()); p("crop now: "+b.get("crop")); } p("Watching crop @1Hz"); }catch(Exception e){p("subscribeFrameMetadata failed: "+e);} }
protected void onDestroy(){ PortalSmartCamera.removeStateListener(cameraStateListener); micLoop=false;try{if(venc!=null){venc.stop();venc.release();}}catch(Exception e){} try{if(aenc!=null){aenc.stop();aenc.release();}}catch(Exception e){} try{if(httpSock!=null)httpSock.close();}catch(Exception e){} if(cam!=null)cam.close();if(reader!=null)reader.close();if(ht!=null)ht.quitSafely();super.onDestroy();}
}
@@ -0,0 +1,15 @@
package com.portaltv.capability
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
class PortalBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
val service = Intent(context, PortalStreamingService::class.java)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(service) else context.startService(service)
}
}
}
@@ -0,0 +1,80 @@
package com.portaltv.capability
import android.content.Context
import android.os.Build
import android.provider.Settings
/** Resolves a human-readable Portal identity for mDNS / UI. */
object PortalDeviceIdentity {
data class Info(val name: String, val model: String) {
/** DNS-SD instance name shown in discovery UIs. */
val serviceName: String
get() {
val n = sanitize(name)
val m = sanitize(model)
return when {
n.isEmpty() && m.isEmpty() -> PortalEndpoints.MDNS_FALLBACK_NAME
n.isEmpty() -> m
m.isEmpty() || n.equals(m, ignoreCase = true) -> n
else -> "$n ($m)"
}
}
}
fun resolve(context: Context): Info {
val model = firstNonBlank(
Build.MODEL,
Build.PRODUCT,
"PortalTV",
)
val name = firstNonBlank(
settings(context, "bluetooth_name"),
settings(context, Settings.Global.DEVICE_NAME),
bluetoothAdapterName(),
model,
)
return Info(name = name, model = model)
}
private fun settings(context: Context, key: String): String? =
try {
Settings.Secure.getString(context.contentResolver, key)
?: Settings.Global.getString(context.contentResolver, key)
} catch (_: Exception) {
null
}
private fun bluetoothAdapterName(): String? =
try {
@Suppress("DEPRECATION")
android.bluetooth.BluetoothAdapter.getDefaultAdapter()?.name
} catch (_: Exception) {
null
}
private fun firstNonBlank(vararg values: String?): String =
values.firstOrNull { !it.isNullOrBlank() }?.trim().orEmpty()
/** DNS-SD instance names: printable, ≤63 bytes, no dots (NsdManager quirk). */
fun sanitize(raw: String): String {
val cleaned = buildString(raw.length) {
for (ch in raw.trim()) {
when {
ch.isLetterOrDigit() || ch == ' ' || ch == '-' || ch == '_' || ch == '(' || ch == ')' ->
append(ch)
ch == '.' || ch == ',' || ch == ':' || ch == '/' ->
append(' ')
else -> Unit
}
}
}.replace(Regex("\\s+"), " ").trim()
if (cleaned.isEmpty()) return ""
val bytes = cleaned.toByteArray(Charsets.UTF_8)
if (bytes.size <= 63) return cleaned
var end = cleaned.length
while (end > 0 && cleaned.substring(0, end).toByteArray(Charsets.UTF_8).size > 63) {
end--
}
return cleaned.substring(0, end).trimEnd()
}
}
@@ -0,0 +1,13 @@
package com.portaltv.capability
/** Shared HTTPS / DNS-SD endpoints for PortalCam. */
object PortalEndpoints {
/** "TV" as ASCII little-endian nibble joke → decimal 5654. */
const val PORT = 5654
/** DNS-SD service type (trailing dot required by NsdManager). */
const val MDNS_TYPE = "_portalcam._tcp."
/** Fallback instance name when device identity is unavailable. */
const val MDNS_FALLBACK_NAME = "PortalCam"
}
@@ -0,0 +1,72 @@
package com.portaltv.capability
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log
/** Registers the Portal HTTPS endpoint on the LAN via DNS-SD / mDNS. */
class PortalMdns(context: Context) {
private val appContext = context.applicationContext
private val nsd = appContext.getSystemService(Context.NSD_SERVICE) as NsdManager
@Volatile private var registered: NsdServiceInfo? = null
@Volatile private var registering = false
private val listener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(info: NsdServiceInfo) {
registered = info
registering = false
Log.i(TAG, "mDNS registered ${info.serviceName} ${info.serviceType}:${info.port}")
}
override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {
registering = false
Log.e(TAG, "mDNS registration failed code=$errorCode name=${info.serviceName}")
}
override fun onServiceUnregistered(info: NsdServiceInfo) {
registered = null
Log.i(TAG, "mDNS unregistered ${info.serviceName}")
}
override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "mDNS unregistration failed code=$errorCode")
}
}
fun register(port: Int = PortalEndpoints.PORT) {
if (registered != null || registering) return
registering = true
val identity = PortalDeviceIdentity.resolve(appContext)
val info = NsdServiceInfo().apply {
serviceName = identity.serviceName
serviceType = PortalEndpoints.MDNS_TYPE
setPort(port)
setAttribute("model", identity.model)
setAttribute("name", identity.name)
}
Log.i(TAG, "mDNS registering as \"${identity.serviceName}\" (name=${identity.name} model=${identity.model})")
try {
nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
} catch (e: Exception) {
registering = false
Log.e(TAG, "mDNS registerService threw", e)
}
}
fun unregister() {
val info = registered
registered = null
registering = false
if (info == null) return
try {
nsd.unregisterService(listener)
} catch (e: Exception) {
Log.w(TAG, "mDNS unregister failed", e)
}
}
companion object {
private const val TAG = "PortalMdns"
}
}
@@ -0,0 +1,229 @@
package com.portaltv.capability
import android.content.Context
import com.portaltv.smartcamera.ControlSnapshot
import com.portaltv.smartcamera.CropConfig
import com.portaltv.smartcamera.DeskModeController
import com.portaltv.smartcamera.SmartCameraController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.json.JSONObject
import java.util.concurrent.CopyOnWriteArrayList
/**
* Process-wide Smart Camera handle backed by [SmartCameraController].
* Shared by [PortalStreamingService] (HTTPS /control + SSE) and [MainActivity] (TV UI).
*
* State is event-driven from [SmartCameraController.control]; mutations ack only —
* observe [states] / [StateListener] for updates.
*/
object PortalSmartCamera {
private const val TIMEOUT_MS = 8_000L
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@Volatile private var camera: SmartCameraController? = null
private var collectJob: Job? = null
@Volatile private var latest: State = State("DefaultAuto", JSONObject())
private val _states = MutableSharedFlow<State>(replay = 1, extraBufferCapacity = 16)
/** Hot stream of camera state for SSE / coroutines. Replay=1 → new collectors get latest. */
val states: SharedFlow<State> = _states.asSharedFlow()
private val listeners = CopyOnWriteArrayList<StateListener>()
fun interface StateListener {
fun onState(state: State)
}
data class State(val mode: String, val config: JSONObject) {
fun toJson(): String = JSONObject()
.put("mode", mode)
.put("config", config)
.toString()
/** Content equality — [JSONObject] is identity-based by default. */
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is State) return false
return mode == other.mode && config.toString() == other.config.toString()
}
override fun hashCode(): Int = 31 * mode.hashCode() + config.toString().hashCode()
}
sealed class Outcome {
data object Ack : Outcome() {
fun toJson(): String = JSONObject().put("ok", true).toString()
}
data class Err(val code: String, val message: String, val httpStatus: Int = 500) : Outcome() {
fun toJson(): String = JSONObject()
.put("error", code)
.put("message", message)
.toString()
}
}
@JvmStatic
fun start(context: Context) {
if (camera != null) return
synchronized(this) {
if (camera != null) return
val c = SmartCameraController(context.applicationContext, scope).also {
it.start(trackCrop = true)
}
camera = c
collectJob?.cancel()
collectJob = scope.launch {
c.control
.map { it.toPortalState() }
.distinctUntilChanged()
.collect { publish(it) }
}
}
}
@JvmStatic
fun currentState(): State = latest
/** Current state as JSON (one-shot; prefer SSE `/control/events`). */
@JvmStatic
fun stateJsonBlocking(): String = latest.toJson()
@JvmStatic
fun addStateListener(listener: StateListener) {
listeners.add(listener)
listener.onState(latest)
}
@JvmStatic
fun removeStateListener(listener: StateListener) {
listeners.remove(listener)
}
@JvmStatic
fun applyModeBlocking(mode: String): Outcome = mutateBlocking {
applyMode(mode, centerX = null, centerY = null, scale = null)
}
@JvmStatic
fun applyModeBlocking(
mode: String,
centerX: Float,
centerY: Float,
scale: Float,
): Outcome = mutateBlocking {
applyMode(mode, centerX, centerY, scale)
}
@JvmStatic
fun applyDeskTightnessBlocking(tightness: Float): Outcome = mutateBlocking {
applyDeskTightness(tightness)
}
/** Fire-and-forget for TV UI buttons. State arrives via [StateListener]. */
@JvmStatic
@JvmOverloads
fun setMode(mode: String, centerX: Float = 0.5f, centerY: Float = 0.5f, scale: Float = 1f) {
scope.launch {
runCatching {
if (mode.removePrefix("ModeSetting_") == "Fixed") {
applyMode(mode, centerX, centerY, scale)
} else {
applyMode(mode, null, null, null)
}
}.onFailure { android.util.Log.e("PortalSmartCamera", "setMode failed", it) }
}
}
@JvmStatic
fun setDeskTightness(tightness: Float) {
scope.launch {
runCatching { applyDeskTightness(tightness) }
.onFailure { android.util.Log.e("PortalSmartCamera", "setDeskTightness failed", it) }
}
}
private fun mutateBlocking(block: suspend () -> Unit): Outcome = runCatching {
runBlocking {
withTimeout(TIMEOUT_MS) { block() }
}
Outcome.Ack
}.getOrElse { e ->
Outcome.Err("set_mode_failed", e.message ?: e.toString())
}
private suspend fun applyMode(
mode: String,
centerX: Float?,
centerY: Float?,
scale: Float?,
) {
val c = camera ?: error("smart camera not started")
val short = mode.removePrefix("ModeSetting_")
val ok = when (short) {
"DefaultAuto" -> c.auto.activate()
"Desk" -> c.desk.activate(c.desk.tuning.value)
"Meeting" -> c.meeting.activate()
"Fixed" -> {
val crop = CropConfig(
centerX ?: c.fixed.crop.value.centerX,
centerY ?: c.fixed.crop.value.centerY,
scale ?: c.fixed.crop.value.scale,
).clamped()
c.fixed.setCrop(crop)
}
else -> error("mode must be DefaultAuto, Desk, Meeting, or Fixed")
}
if (!ok) error("setMode($short) was not accepted by Smart Camera")
}
private suspend fun applyDeskTightness(tightness: Float) {
val c = camera ?: error("smart camera not started")
val ok = c.desk.activate(DeskModeController.Tuning(framingTightness = tightness))
if (!ok) error("setMode(Desk) was not accepted by Smart Camera")
}
private fun publish(state: State) {
latest = state
_states.tryEmit(state)
for (l in listeners) {
runCatching { l.onState(state) }
.onFailure { android.util.Log.w("PortalSmartCamera", "listener failed", it) }
}
}
private fun ControlSnapshot.toPortalState(): State {
val short = shortMode?.takeIf { it in MODES } ?: latest.mode.takeIf { it in MODES } ?: "DefaultAuto"
val config = JSONObject()
when (short) {
"Fixed" -> {
config.putRounded("centerX", fixedCrop.centerX)
config.putRounded("centerY", fixedCrop.centerY)
config.putRounded("scale", fixedCrop.scale)
}
"Desk" -> {
deskTuning.framingTightness?.let { config.putRounded("framingTightness", it) }
deskTuning.trackingResponseDelayPct?.let { config.putRounded("trackingResponseDelayPct", it) }
deskTuning.trackingSensitivityPct?.let { config.putRounded("trackingSensitivityPct", it) }
deskTuning.transitionSpeedPct?.let { config.putRounded("transitionSpeedPct", it) }
}
}
return State(short, config)
}
private fun JSONObject.putRounded(key: String, value: Float) {
put(key, Math.round(value * 1000.0) / 1000.0)
}
private val MODES = setOf("DefaultAuto", "Desk", "Meeting", "Fixed")
}
@@ -0,0 +1,198 @@
package com.portaltv.capability
import android.util.Base64
import java.math.BigInteger
import java.security.MessageDigest
import java.security.SecureRandom
object PortalSrp {
// RFC 5054 2048-bit prime
private const val N_HEX =
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74" +
"020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437" +
"4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" +
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05" +
"98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB" +
"9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" +
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718" +
"3995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF"
val N = BigInteger(N_HEX, 16)
val g = BigInteger.valueOf(2)
val k: BigInteger
init {
val nBytes = toPadded256(N)
val gBytes = toPadded256(g)
k = BigInteger(1, sha256(nBytes, gBytes))
}
private val random = SecureRandom()
data class ActivePairing(
val id: String,
val pin: String,
val salt: ByteArray,
val v: BigInteger,
val privB: BigInteger,
val pubB: BigInteger,
val expiresAt: Long,
var attemptsLeft: Int = 3
)
fun toPadded256(bi: BigInteger): ByteArray {
val raw = bi.toByteArray()
val result = ByteArray(256)
if (raw.size > 256) {
System.arraycopy(raw, raw.size - 256, result, 0, 256)
} else {
System.arraycopy(raw, 0, result, 256 - raw.size, raw.size)
}
return result
}
fun sha256(vararg parts: ByteArray): ByteArray {
val md = MessageDigest.getInstance("SHA-256")
for (p in parts) md.update(p)
return md.digest()
}
fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean {
if (a.size != b.size) return false
var result = 0
for (i in a.indices) {
result = result or (a[i].toInt() xor b[i].toInt())
}
return result == 0
}
fun isValidPublicA(A: BigInteger): Boolean = A.mod(N) != BigInteger.ZERO
fun isValidPublicB(B: BigInteger): Boolean = B.mod(N) != BigInteger.ZERO
fun isValidScrambler(u: BigInteger): Boolean = u != BigInteger.ZERO
fun newPairing(pin: String): ActivePairing {
val id = Base64.encodeToString(
ByteArray(12).also { random.nextBytes(it) },
Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
)
val salt = ByteArray(16).also { random.nextBytes(it) }
// x = SHA256(salt || PIN)
val xBytes = sha256(salt, pin.toByteArray(Charsets.UTF_8))
val x = BigInteger(1, xBytes)
// v = g^x mod N
val v = g.modPow(x, N)
// b = random 256-bit BigInteger, ensuring B mod N != 0
var b: BigInteger
var B: BigInteger
do {
val bBytes = ByteArray(32).also { random.nextBytes(it) }
b = BigInteger(1, bBytes).mod(N.subtract(BigInteger.ONE)).add(BigInteger.ONE)
val gb = g.modPow(b, N)
B = k.multiply(v).add(gb).mod(N)
} while (!isValidPublicB(B))
return ActivePairing(
id = id,
pin = pin,
salt = salt,
v = v,
privB = b,
pubB = B,
expiresAt = System.currentTimeMillis() + 120_000L, // 2 minutes
attemptsLeft = 3
)
}
sealed class VerifyResult {
data class Success(val M2: ByteArray, val token: String) : VerifyResult()
data class Failed(val attemptsLeft: Int, val message: String) : VerifyResult()
}
fun verifyClient(
pairing: ActivePairing,
A_hex: String,
M1_hex: String,
tlsHash: ByteArray
): VerifyResult {
if (System.currentTimeMillis() > pairing.expiresAt) {
return VerifyResult.Failed(0, "Pairing session expired")
}
if (pairing.attemptsLeft <= 0) {
return VerifyResult.Failed(0, "Too many failed attempts; pairing cancelled")
}
val A = try {
BigInteger(A_hex, 16)
} catch (_: Exception) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Invalid client public key format")
}
// A mod N != 0
if (!isValidPublicA(A)) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Invalid public key A")
}
val M1 = try {
hexToBytes(M1_hex)
} catch (_: Exception) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Invalid M1 format")
}
val A_bytes = toPadded256(A)
val B_bytes = toPadded256(pairing.pubB)
// u = SHA256(PAD(A) || PAD(B))
val uBytes = sha256(A_bytes, B_bytes)
val u = BigInteger(1, uBytes)
if (!isValidScrambler(u)) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Scrambler u is zero")
}
// Server computes S = (A * v^u mod N)^b mod N
val vu = pairing.v.modPow(u, N)
val S = A.multiply(vu).mod(N).modPow(pairing.privB, N)
val S_bytes = toPadded256(S)
// K = SHA256(PAD(S))
val K = sha256(S_bytes)
// Expected M1 = SHA256(PAD(A) || PAD(B) || K || salt || tlsHash)
val expectedM1 = sha256(A_bytes, B_bytes, K, pairing.salt, tlsHash)
if (!constantTimeEquals(M1, expectedM1)) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Authentication failed (wrong PIN or MITM detected)")
}
// M2 = SHA256(PAD(A) || M1 || K || tlsHash)
val M2 = sha256(A_bytes, M1, K, tlsHash)
// Generate cryptographically secure bearer token
val tokenBytes = ByteArray(32).also { random.nextBytes(it) }
val token = Base64.encodeToString(tokenBytes, Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE)
return VerifyResult.Success(M2 = M2, token = token)
}
fun bytesToHex(bytes: ByteArray): String =
bytes.joinToString("") { "%02x".format(it) }
fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim()
val len = clean.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] = ((Character.digit(clean[i], 16) shl 4) + Character.digit(clean[i + 1], 16)).toByte()
i += 2
}
return data
}
}
@@ -0,0 +1,105 @@
package com.portaltv.capability
import java.math.BigInteger
import java.security.SecureRandom
/**
* SRP-6a client implementation matching RFC 5054 2048-bit MODP group
* with cryptographic TLS channel binding.
*
* Compatible with PortalCam client (PortalSrpClient.swift) and PortalSrp server.
*/
class PortalSrpClient(
customA: BigInteger? = null,
private val random: SecureRandom = SecureRandom()
) {
@get:JvmName("getPrivateA")
val a: BigInteger
@get:JvmName("getPublicA")
val A: BigInteger
var K: ByteArray? = null
private set
var M1: ByteArray? = null
private set
private var tlsCertHash: ByteArray? = null
init {
if (customA != null) {
a = customA
} else {
val aBytes = ByteArray(32).also { random.nextBytes(it) }
a = BigInteger(1, aBytes).mod(PortalSrp.N.subtract(BigInteger.valueOf(2))).add(BigInteger.ONE)
}
A = PortalSrp.g.modPow(a, PortalSrp.N)
}
val pubAHex: String
get() = PortalSrp.bytesToHex(PortalSrp.toPadded256(A))
/**
* Compute M1 using server parameters, user PIN, and captured TLS certificate SHA-256 hash.
* Enforces safety checks: B mod N != 0 and u != 0.
*/
fun computeM1(saltHex: String, pubBHex: String, pin: String, tlsCertSha256: ByteArray): String {
val salt = PortalSrp.hexToBytes(saltHex)
require(salt.isNotEmpty()) { "Invalid salt hex" }
val bBytes = PortalSrp.hexToBytes(pubBHex)
val B = BigInteger(1, bBytes)
// Safety check B % N != 0
require(PortalSrp.isValidPublicB(B)) { "Server public value B % N == 0" }
// u = SHA256(pad256(A) || pad256(B))
val uBytes = PortalSrp.sha256(PortalSrp.toPadded256(A), PortalSrp.toPadded256(B))
val u = BigInteger(1, uBytes)
require(PortalSrp.isValidScrambler(u)) { "Computed u == 0" }
// x = SHA256(salt || UTF8(pin))
val xBytes = PortalSrp.sha256(salt, pin.toByteArray(Charsets.UTF_8))
val x = BigInteger(1, xBytes)
// S = (B - k * (g^x mod N) mod N) ^ (a + u * x) mod N
val gx = PortalSrp.g.modPow(x, PortalSrp.N)
val kgx = PortalSrp.k.multiply(gx).mod(PortalSrp.N)
val base = B.subtract(kgx).mod(PortalSrp.N)
val exp = a.add(u.multiply(x))
val S = base.modPow(exp, PortalSrp.N)
// K = SHA256(pad256(S))
val sessionK = PortalSrp.sha256(PortalSrp.toPadded256(S))
this.K = sessionK
this.tlsCertHash = tlsCertSha256
// M1 = SHA256(pad256(A) || pad256(B) || K || salt || tlsCertSha256)
val clientM1 = PortalSrp.sha256(
PortalSrp.toPadded256(A),
PortalSrp.toPadded256(B),
sessionK,
salt,
tlsCertSha256
)
this.M1 = clientM1
return PortalSrp.bytesToHex(clientM1)
}
/**
* Verify server's M2 response.
* Expected M2 = SHA256(pad256(A) || M1 || K || tlsCertSha256).
*/
fun verifyServerM2(serverM2Hex: String): Boolean {
val expectedM1 = M1 ?: throw IllegalStateException("Client state not initialized for verification")
val sessionK = K ?: throw IllegalStateException("Client state not initialized for verification")
val certHash = tlsCertHash ?: throw IllegalStateException("Client state not initialized for verification")
val serverM2 = try {
PortalSrp.hexToBytes(serverM2Hex)
} catch (e: Exception) {
return false
}
val expectedM2 = PortalSrp.sha256(PortalSrp.toPadded256(A), expectedM1, sessionK, certHash)
return PortalSrp.constantTimeEquals(expectedM2, serverM2)
}
}
@@ -0,0 +1,746 @@
package com.portaltv.capability
import android.app.*
import android.content.*
import android.graphics.SurfaceTexture
import android.hardware.camera2.*
import android.media.*
import android.os.*
import android.view.Surface
import java.net.*
import java.util.concurrent.*
import java.util.concurrent.atomic.AtomicInteger
import java.security.MessageDigest
import java.security.SecureRandom
import android.util.Base64
import javax.net.ssl.SSLServerSocket
/** Foreground, UI-independent Portal raw media service over HTTPS. */
class PortalStreamingService : Service() {
companion object { @JvmField @Volatile var activityVisible = false }
private val video = Track(true); private val audio = Track(false)
private val videoUsers = AtomicInteger(); private val audioUsers = AtomicInteger()
private var camera: CameraDevice? = null; private var cameraSession: CameraCaptureSession? = null
private var reader: MediaCodec? = null; private var audioCodec: MediaCodec? = null
private var videoSurface: Surface? = null; private var mic: AudioRecord? = null
private var server: SSLServerSocket? = null; private val io = Executors.newCachedThreadPool()
private val cameraHandler = Handler(Looper.getMainLooper())
private val videoLock = Any()
private val audioLock = Any()
@Volatile private var audioLoopActive = false
private var audioThread: Thread? = null
private val authPrefs by lazy { getSharedPreferences("auth", MODE_PRIVATE) }
private val random = SecureRandom()
@Volatile private var pairing: PortalSrp.ActivePairing? = null
@Volatile private var recoveringCamera = false
/** Bumped on every startVideo/stopVideo so stale CameraDevice callbacks are ignored. */
@Volatile private var cameraGeneration = 0
private var mdns: PortalMdns? = null
override fun onCreate() {
super.onCreate()
android.util.Log.d("PortalService", "onCreate - initializing TLS")
startForeground(42, notification())
PortalSmartCamera.start(this)
try {
server = PortalTls.createServerSocket(this, PortalEndpoints.PORT)
android.util.Log.i("PortalService", "HTTPS server listening on port ${PortalEndpoints.PORT}")
mdns = PortalMdns(this).also { it.register(PortalEndpoints.PORT) }
} catch (e: Exception) {
android.util.Log.e("PortalService", "Failed to start HTTPS server on port ${PortalEndpoints.PORT}", e)
}
cameraHandler.post(object : Runnable {
override fun run() {
authPrefs.edit()
.putInt("activeVideo", videoUsers.get())
.putInt("activeAudio", audioUsers.get())
.apply()
cameraHandler.postDelayed(this, 1000)
}
})
Thread { acceptLoop() }.start()
}
override fun onStartCommand(i: Intent?, flags: Int, id: Int): Int {
if (i?.action == "com.portaltv.capability.CLOSE_CLIENTS") {
video.clear(); audio.clear(); return START_STICKY
}
if (i?.action == "com.portaltv.capability.REVOKE_ALL") {
authPrefs.edit().clear().apply(); video.clear(); audio.clear()
android.util.Log.i("PortalService", "all clients revoked")
return START_STICKY
}
if (videoUsers.get() > 0 && reader == null) startVideo()
return START_STICKY
}
override fun onBind(i: Intent?): IBinder? = null
private fun notification(): Notification {
val ch = NotificationChannel("portal", "Portal camera", NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
return Notification.Builder(this, "portal")
.setContentTitle("Portal camera streaming (HTTPS)")
.setSmallIcon(android.R.drawable.presence_video_online)
.build()
}
private fun acceptLoop() {
val listener = server ?: return
while (!listener.isClosed) {
runCatching {
val client = listener.accept()
io.submit { handle(client) }
}.onFailure {
if (!listener.isClosed) {
android.util.Log.w("PortalService", "accept failed", it)
}
}
}
}
private fun hash(s: String) =
MessageDigest.getInstance("SHA-256").digest(s.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
private fun startSrpPairing(): PortalSrp.ActivePairing {
val existing = pairing
if (existing != null && System.currentTimeMillis() < existing.expiresAt && existing.attemptsLeft > 0) {
android.util.Log.i("PortalService", "SRP pairing started with PIN: ${existing.pin}")
return existing
}
val pin = (100000 + random.nextInt(900000)).toString()
val active = PortalSrp.newPairing(pin)
pairing = active
authPrefs.edit().putString("pairingPin", pin).apply()
cameraHandler.post {
android.widget.Toast.makeText(this, "Pairing PIN: $pin", android.widget.Toast.LENGTH_LONG).show()
}
bringActivityToFront()
android.util.Log.i("PortalService", "SRP pairing started with PIN: $pin")
return active
}
private fun authToken(headers: Map<String, String>): String? {
val a = headers["authorization"] ?: return null
if (!a.startsWith("Bearer ")) return null
val token = a.substring(7)
val h = hash(token)
return if ((authPrefs.getStringSet("tokens", emptySet()) ?: emptySet()).contains(h)) token else null
}
private fun handle(s: Socket) {
s.use { socket ->
socket.soTimeout = 5000
val reader = socket.getInputStream().bufferedReader()
val line = reader.readLine() ?: return
val headers = mutableMapOf<String, String>()
while (true) {
val h = reader.readLine() ?: break
if (h.isEmpty()) break
val k = h.indexOf(':')
if (k > 0) headers[h.substring(0, k).lowercase()] = h.substring(k + 1).trim()
}
val parts = line.split(" ")
val method = parts.getOrNull(0) ?: "GET"
val path = parts.getOrNull(1) ?: return
// If request has Content-Length, read body
var body = ""
val contentLength = headers["content-length"]?.toIntOrNull() ?: 0
if (contentLength in 1..65536) {
val buf = CharArray(contentLength)
var readTotal = 0
while (readTotal < contentLength) {
val r = reader.read(buf, readTotal, contentLength - readTotal)
if (r < 0) break
readTotal += r
}
body = String(buf, 0, readTotal)
}
when {
// SRP-6a pairing endpoints
path.startsWith("/auth/srp/init") -> handleSrpInit(socket)
path.startsWith("/auth/srp/verify") -> handleSrpVerify(socket, path, body, headers)
// TLS Info endpoint (returns server cert SHA-256 for diagnostics)
path.startsWith("/auth/cert") -> {
val sha = PortalSrp.bytesToHex(PortalTls.certSha256)
reply(socket, 200, "{\"certSha256\":\"$sha\"}", "application/json")
}
// Media streams (require Bearer auth)
path.startsWith("/video.h264") -> {
if (authToken(headers) != null) stream(socket, video, videoUsers, "video/h264", true)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
}
path.startsWith("/audio.aac") -> {
if (authToken(headers) != null) stream(socket, audio, audioUsers, "audio/aac", false)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
}
path.startsWith("/control") -> {
if (authToken(headers) != null) control(socket, path)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
}
else -> reply(socket, 404, "{\"error\":\"not found\"}", "application/json")
}
}
}
private fun handleSrpInit(s: Socket) {
val p = startSrpPairing()
val saltHex = PortalSrp.bytesToHex(p.salt)
val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(p.pubB))
val json = "{\"pairingId\":\"${p.id}\",\"salt\":\"$saltHex\",\"B\":\"$bHex\",\"expiresIn\":120}"
reply(s, 200, json, "application/json")
}
private fun handleSrpVerify(s: Socket, path: String, body: String, headers: Map<String, String>) {
val p = pairing
if (p == null) {
reply(s, 400, "{\"error\":\"no_active_pairing\",\"message\":\"Call /auth/srp/init first\"}", "application/json")
return
}
// Parse params from query string or JSON body
val queryParams = path.substringAfter('?', "")
.split('&')
.mapNotNull {
val kv = it.split('=', limit = 2)
if (kv.size == 2) URLDecoder.decode(kv[0], "UTF-8") to URLDecoder.decode(kv[1], "UTF-8") else null
}.toMap()
fun extractParam(key: String): String? {
queryParams[key]?.let { return it }
// Basic JSON search: "key":"value" or "key": "value"
val pattern = Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"")
return pattern.find(body)?.groupValues?.getOrNull(1)
}
val pairingId = extractParam("pairingId")
val A = extractParam("A")
val M1 = extractParam("M1")
if (pairingId == null || A == null || M1 == null) {
reply(s, 400, "{\"error\":\"missing_parameters\",\"message\":\"pairingId, A, and M1 are required\"}", "application/json")
return
}
if (pairingId != p.id) {
reply(s, 400, "{\"error\":\"invalid_pairing_id\"}", "application/json")
return
}
val tlsHash = PortalTls.certSha256
val res = PortalSrp.verifyClient(p, A, M1, tlsHash)
when (res) {
is PortalSrp.VerifyResult.Success -> {
val token = res.token
val h = hash(token)
val set = (authPrefs.getStringSet("tokens", emptySet()) ?: emptySet()).toMutableSet()
set += h
val clientMeta = (headers["user-agent"] ?: "unknown") + "|" +
(headers["x-client-name"] ?: "") + "|" +
(headers["x-client-version"] ?: "") + "|" +
System.currentTimeMillis()
authPrefs.edit()
.putStringSet("tokens", set)
.putString("client.$h", clientMeta)
.remove("pairingPin")
.apply()
pairing = null
val m2Hex = PortalSrp.bytesToHex(res.M2)
val json = "{\"M2\":\"$m2Hex\",\"token\":\"$token\"}"
android.util.Log.i("PortalService", "SRP pairing successfully completed for client: $clientMeta")
reply(s, 200, json, "application/json")
}
is PortalSrp.VerifyResult.Failed -> {
android.util.Log.w("PortalService", "SRP verify failed: ${res.message}, attempts left: ${res.attemptsLeft}")
if (res.attemptsLeft <= 0) {
pairing = null
authPrefs.edit().remove("pairingPin").apply()
}
reply(
s,
401,
"{\"error\":\"authentication_failed\",\"attemptsLeft\":${res.attemptsLeft},\"message\":\"${res.message}\"}",
"application/json"
)
}
}
}
private fun stream(s: Socket, t: Track, n: AtomicInteger, type: String, key: Boolean) {
s.soTimeout = 0 // Don't timeout streaming connections
val o = s.getOutputStream()
o.write("HTTP/1.1 200 OK\r\nContent-Type: $type\r\nCache-Control: no-store\r\nConnection: keep-alive\r\n\r\n".toByteArray())
o.flush()
val q = t.add(key)
val active = n.incrementAndGet()
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client connected; active=$active")
if (active == 1) {
if (t === video) startVideo() else startAudio()
if (key) bringActivityToFront()
}
try {
var wait = key
while (!s.isClosed && t.contains(q)) {
val p = q.poll(1, TimeUnit.SECONDS) ?: continue
if (!wait || p.key) {
wait = false
o.write(p.data)
o.flush()
}
}
} catch (e: Exception) {
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client disconnected: ${e.javaClass.simpleName}")
} finally {
t.remove(q)
val left = n.decrementAndGet()
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client removed; active=$left")
if (left == 0) {
if (t === video) {
android.util.Log.i("PortalService", "last video client gone; stopping camera")
stopVideo()
} else {
android.util.Log.i("PortalService", "last audio client gone; stopping microphone")
stopAudio()
}
}
}
}
private fun bringActivityToFront() {
if (activityVisible) return
runCatching {
startActivity(
Intent().setClassName(this, "com.portaltv.capability.MainActivity")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
)
}.onFailure { android.util.Log.w("PortalService", "could not foreground activity", it) }
}
private fun control(s: Socket, p: String) {
val pathOnly = p.substringBefore('?')
val q = p.substringAfter('?', "")
.split('&')
.mapNotNull {
val kv = it.split('=', limit = 2)
if (kv.size == 2) URLDecoder.decode(kv[0], "UTF-8") to URLDecoder.decode(kv[1], "UTF-8") else null
}.toMap()
fun replyOutcome(outcome: PortalSmartCamera.Outcome) {
when (outcome) {
is PortalSmartCamera.Outcome.Ack ->
reply(s, 200, outcome.toJson(), "application/json")
is PortalSmartCamera.Outcome.Err ->
reply(s, outcome.httpStatus, outcome.toJson(), "application/json")
}
}
when {
pathOnly == "/control/events" -> {
controlEvents(s)
}
pathOnly == "/control" || pathOnly == "/control/" || pathOnly == "/control/state" -> {
reply(s, 200, PortalSmartCamera.stateJsonBlocking(), "application/json")
}
pathOnly.startsWith("/control/mode") -> {
val mode = q["mode"]
if (mode == null || mode !in setOf("DefaultAuto", "Desk", "Meeting", "Fixed")) {
reply(
s, 400,
PortalSmartCamera.Outcome.Err(
"invalid_mode",
"mode must be DefaultAuto, Desk, Meeting, or Fixed",
400,
).toJson(),
"application/json",
)
return
}
replyOutcome(PortalSmartCamera.applyModeBlocking(mode))
}
pathOnly.startsWith("/control/fixed") -> {
val x = q["x"]?.toFloatOrNull()
val y = q["y"]?.toFloatOrNull()
val scale = q["scale"]?.toFloatOrNull()
if (x == null || y == null || scale == null) {
reply(
s, 400,
PortalSmartCamera.Outcome.Err(
"invalid_fixed",
"x, y, and scale are required numbers",
400,
).toJson(),
"application/json",
)
return
}
replyOutcome(PortalSmartCamera.applyModeBlocking("Fixed", x, y, scale))
}
pathOnly.startsWith("/control/desk") -> {
val tightness = q["tightness"]?.toFloatOrNull()
if (tightness == null) {
reply(
s, 400,
PortalSmartCamera.Outcome.Err(
"invalid_desk",
"tightness is a required number",
400,
).toJson(),
"application/json",
)
return
}
replyOutcome(PortalSmartCamera.applyDeskTightnessBlocking(tightness))
}
else -> reply(
s, 404,
PortalSmartCamera.Outcome.Err("not_found", "unknown control path", 404).toJson(),
"application/json",
)
}
}
/** SSE: initial state, then `event: state` on each change. */
private fun controlEvents(s: Socket) {
s.soTimeout = 0
val o = s.getOutputStream()
o.write(
("HTTP/1.1 200 OK\r\n" +
"Content-Type: text/event-stream\r\n" +
"Cache-Control: no-store\r\n" +
"Connection: keep-alive\r\n\r\n").toByteArray(Charsets.UTF_8)
)
o.flush()
fun writeEvent(state: PortalSmartCamera.State) {
val payload = "event: state\ndata: ${state.toJson()}\n\n"
o.write(payload.toByteArray(Charsets.UTF_8))
o.flush()
}
val queue = LinkedBlockingQueue<PortalSmartCamera.State>(32)
val listener = PortalSmartCamera.StateListener { state ->
// Drop oldest if slow client; keep connection alive.
while (!queue.offer(state)) {
queue.poll()
}
}
PortalSmartCamera.addStateListener(listener)
android.util.Log.i("PortalService", "SSE /control/events client connected")
try {
// addStateListener already pushed latest; also write a comment keepalive loop.
while (!s.isClosed) {
val next = queue.poll(15, TimeUnit.SECONDS)
if (next != null) {
writeEvent(next)
} else {
o.write(": keepalive\n\n".toByteArray(Charsets.UTF_8))
o.flush()
}
}
} catch (e: Exception) {
android.util.Log.i("PortalService", "SSE client disconnected: ${e.javaClass.simpleName}")
} finally {
PortalSmartCamera.removeStateListener(listener)
android.util.Log.i("PortalService", "SSE /control/events client removed")
runCatching { s.close() }
}
}
private fun reply(s: Socket, c: Int, b: String, contentType: String = "text/plain") {
val reason = when (c) {
200 -> "OK"
400 -> "Bad Request"
401 -> "Unauthorized"
404 -> "Not Found"
else -> "Error"
}
val d = b.toByteArray(Charsets.UTF_8)
s.getOutputStream().write(
("HTTP/1.1 $c $reason\r\nContent-Type: $contentType\r\nContent-Length: ${d.size}\r\nConnection: close\r\n\r\n").toByteArray(Charsets.UTF_8) + d
)
}
private fun startVideo() {
synchronized(videoLock) {
if (reader != null && videoSurface != null) {
android.util.Log.d("PortalService", "startVideo skipped; encoder already running")
return
}
android.util.Log.d("PortalService", "startVideo")
try {
// Tear down any half-open previous pipeline before starting a new one.
stopVideoLocked()
val generation = ++cameraGeneration
val f = MediaFormat.createVideoFormat("video/avc", 1280, 720)
f.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
f.setInteger(MediaFormat.KEY_BIT_RATE, 2500000)
f.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
f.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
reader = MediaCodec.createEncoderByType("video/avc")
reader!!.configure(f, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
videoSurface = reader!!.createInputSurface()
reader!!.start()
openCamera(generation, videoSurface!!)
drainVideo()
} catch (e: Exception) {
android.util.Log.e("PortalService", "startVideo failed", e)
stopVideoLocked()
}
}
}
private fun drainVideo() {
Thread {
val b = MediaCodec.BufferInfo()
var count = 0
while (reader != null) try {
val i = reader!!.dequeueOutputBuffer(b, 10000)
if (i >= 0) {
val x = reader!!.getOutputBuffer(i)
if (x != null && b.size > 0) {
val d = ByteArray(b.size)
x.position(b.offset)
x.get(d)
val key = (b.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0 || (b.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0
video.publish(d, key, b.presentationTimeUs)
if (++count % 30 == 0) android.util.Log.d("PortalService", "video packets=$count bytes=${d.size} key=$key")
}
reader!!.releaseOutputBuffer(i, false)
}
} catch (e: Exception) {
if (reader != null) android.util.Log.e("PortalService", "video drain stopped", e)
break
}
}.start()
}
private fun openCamera(generation: Int, surface: Surface) {
val cm = getSystemService(CameraManager::class.java)
val id = cm.cameraIdList.firstOrNull()
if (id == null) {
android.util.Log.e("PortalService", "no cameras available")
return
}
if (checkSelfPermission("android.permission.CAMERA") != 0) {
android.util.Log.e("PortalService", "camera permission denied")
return
}
android.util.Log.d("PortalService", "opening camera $id (gen=$generation)")
try {
cm.openCamera(id, object : CameraDevice.StateCallback() {
override fun onOpened(c: CameraDevice) {
if (generation != cameraGeneration || surface !== videoSurface) {
android.util.Log.w("PortalService", "stale camera onOpened (gen=$generation); closing")
runCatching { c.close() }
return
}
android.util.Log.d("PortalService", "camera opened (gen=$generation)")
try {
camera = c
val q = c.createCaptureRequest(CameraDevice.TEMPLATE_RECORD)
q.addTarget(surface)
c.createCaptureSession(listOf(surface), object : CameraCaptureSession.StateCallback() {
override fun onConfigured(s: CameraCaptureSession) {
if (generation != cameraGeneration) {
android.util.Log.w("PortalService", "stale capture onConfigured; closing session")
runCatching { s.close() }
return
}
android.util.Log.d("PortalService", "capture configured")
try {
cameraSession = s
s.setRepeatingRequest(q.build(), null, cameraHandler)
} catch (e: Exception) {
android.util.Log.e("PortalService", "setRepeatingRequest failed", e)
recoverCamera()
}
}
override fun onConfigureFailed(s: CameraCaptureSession) {
android.util.Log.e("PortalService", "capture configure failed")
recoverCamera()
}
}, cameraHandler)
} catch (e: Exception) {
android.util.Log.e("PortalService", "camera onOpened setup failed", e)
runCatching { c.close() }
recoverCamera()
}
}
override fun onDisconnected(c: CameraDevice) {
android.util.Log.e("PortalService", "camera disconnected")
runCatching { c.close() }
if (generation == cameraGeneration) recoverCamera()
}
override fun onError(c: CameraDevice, e: Int) {
android.util.Log.e("PortalService", "camera error $e")
runCatching { c.close() }
if (generation == cameraGeneration) recoverCamera()
}
}, cameraHandler)
} catch (e: Exception) {
android.util.Log.e("PortalService", "openCamera failed", e)
recoverCamera()
}
}
private fun recoverCamera() {
if (videoUsers.get() <= 0 || recoveringCamera) return
recoveringCamera = true
cameraHandler.postDelayed({
recoveringCamera = false
if (videoUsers.get() > 0) {
stopVideo()
startVideo()
}
}, 750)
}
private fun stopVideo() {
synchronized(videoLock) {
stopVideoLocked()
}
}
private fun stopVideoLocked() {
cameraGeneration++
runCatching { cameraSession?.close() }
runCatching { camera?.close() }
cameraSession = null
camera = null
runCatching { videoSurface?.release() }
videoSurface = null
runCatching {
reader?.stop()
reader?.release()
}
reader = null
}
private fun startAudio() {
synchronized(audioLock) {
stopAudioLocked()
try {
val f = MediaFormat.createAudioFormat("audio/mp4a-latm", 48000, 1)
f.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
f.setInteger(MediaFormat.KEY_BIT_RATE, 64000)
val codec = MediaCodec.createEncoderByType("audio/mp4a-latm")
codec.configure(f, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codec.start()
audioCodec = codec
val n = AudioRecord.getMinBufferSize(48000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
val record = AudioRecord(
MediaRecorder.AudioSource.DEFAULT,
48000,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
n * 2
)
record.startRecording()
mic = record
audioLoopActive = true
audioThread = Thread {
val pcm = ByteArray(n)
val info = MediaCodec.BufferInfo()
while (audioLoopActive) {
try {
val got = record.read(pcm, 0, pcm.size)
if (!audioLoopActive) break
if (got > 0) {
val i = codec.dequeueInputBuffer(10_000)
if (!audioLoopActive) break
if (i >= 0) {
val x = codec.getInputBuffer(i) ?: continue
x.clear()
x.put(pcm, 0, got)
codec.queueInputBuffer(i, 0, got, System.nanoTime() / 1000, 0)
}
}
val o = codec.dequeueOutputBuffer(info, 0)
if (!audioLoopActive) break
if (o >= 0) {
val x = codec.getOutputBuffer(o)
if (x != null && info.size > 0) {
val d = ByteArray(info.size)
x.position(info.offset)
x.get(d)
audio.publish(d, false, info.presentationTimeUs)
}
codec.releaseOutputBuffer(o, false)
}
} catch (e: Exception) {
if (audioLoopActive) {
android.util.Log.w("PortalService", "audio loop stopped", e)
}
break
}
}
}.also {
it.name = "portal-audio"
it.start()
}
} catch (e: Exception) {
android.util.Log.e("PortalService", "startAudio failed", e)
stopAudioLocked()
}
}
}
private fun stopAudio() {
synchronized(audioLock) {
stopAudioLocked()
}
}
private fun stopAudioLocked() {
audioLoopActive = false
runCatching { mic?.stop() } // unblock AudioRecord.read
val t = audioThread
audioThread = null
if (t != null && t !== Thread.currentThread()) {
runCatching { t.join(750) }
}
runCatching { mic?.release() }
mic = null
runCatching {
audioCodec?.stop()
audioCodec?.release()
}
audioCodec = null
}
override fun onDestroy() {
mdns?.unregister()
mdns = null
server?.close()
stopVideo()
stopAudio()
io.shutdownNow()
super.onDestroy()
}
class Track(val v: Boolean) {
data class P(val data: ByteArray, val key: Boolean, val pts: Long)
private val qs = CopyOnWriteArraySet<LinkedBlockingDeque<P>>()
@Volatile private var config: ByteArray? = null
fun add(k: Boolean) = LinkedBlockingDeque<P>(if (v) 3 else 256).also {
qs += it
config?.let { c -> if (v) it.offer(P(c, true, 0)) }
}
fun clear() { qs.clear() }
fun contains(q: LinkedBlockingDeque<P>) = qs.contains(q)
fun remove(q: LinkedBlockingDeque<P>) { qs -= q }
fun publish(d: ByteArray, k: Boolean, p: Long) {
if (v && k && d.size < 256) config = d
qs.forEach { if (!it.offer(P(d, k, p))) qs -= it }
}
}
}
@@ -0,0 +1,108 @@
package com.portaltv.capability
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.math.BigInteger
import java.net.Socket
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.MessageDigest
import java.security.Principal
import java.security.PrivateKey
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.security.spec.ECGenParameterSpec
import java.util.Date
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLServerSocket
import javax.net.ssl.X509ExtendedKeyManager
import javax.security.auth.x500.X500Principal
object PortalTls {
private const val ALIAS = "portalcam_tls_ec_p256"
private const val KS_TYPE = "AndroidKeyStore"
@Volatile
var certDer: ByteArray = ByteArray(0)
private set
@Volatile
var certSha256: ByteArray = ByteArray(0)
private set
fun getOrCreateSslContext(context: Context): SSLContext {
val ks = KeyStore.getInstance(KS_TYPE).apply { load(null) }
if (!ks.containsAlias(ALIAS)) {
android.util.Log.i("PortalTls", "Generating new EC secp256r1 self-signed certificate in AndroidKeyStore")
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KS_TYPE)
val now = System.currentTimeMillis()
val notBefore = Date(now - 86400000L) // 1 day ago
val notAfter = Date(now + 10L * 365 * 86400000L) // 10 years
val spec = KeyGenParameterSpec.Builder(
ALIAS,
KeyProperties.PURPOSE_SIGN
)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setCertificateSubject(X500Principal("CN=PortalCam, O=PortalCam, C=US"))
.setCertificateSerialNumber(BigInteger.valueOf(now))
.setCertificateNotBefore(notBefore)
.setCertificateNotAfter(notAfter)
.setDigests(
KeyProperties.DIGEST_NONE,
KeyProperties.DIGEST_SHA256,
KeyProperties.DIGEST_SHA384,
KeyProperties.DIGEST_SHA512
)
.build()
kpg.initialize(spec)
kpg.generateKeyPair()
}
val cert = ks.getCertificate(ALIAS) as X509Certificate
certDer = cert.encoded
certSha256 = MessageDigest.getInstance("SHA-256").digest(certDer)
android.util.Log.i("PortalTls", "Certificate SHA-256: ${certSha256.joinToString("") { "%02x".format(it) }}")
// Build KeyManager that retrieves key from AndroidKeyStore
val km = object : X509ExtendedKeyManager() {
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? = null
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> = arrayOf(ALIAS)
override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String = ALIAS
override fun chooseEngineServerAlias(keyType: String?, issuers: Array<out Principal>?, engine: SSLEngine?): String = ALIAS
override fun getCertificateChain(alias: String?): Array<X509Certificate>? = arrayOf(cert)
override fun getPrivateKey(alias: String?): PrivateKey? = ks.getKey(ALIAS, null) as? PrivateKey
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(arrayOf(km), null, SecureRandom())
return sslContext
}
fun createServerSocket(context: Context, port: Int): SSLServerSocket {
val sslCtx = getOrCreateSslContext(context)
val s = sslCtx.serverSocketFactory.createServerSocket(port) as SSLServerSocket
s.needClientAuth = false
s.wantClientAuth = false
val supported = s.supportedProtocols.toList()
android.util.Log.i("PortalTls", "Supported protocols: $supported")
val desired = listOf("TLSv1.3", "TLSv1.2").filter { it in supported }
if (desired.isNotEmpty()) {
s.enabledProtocols = desired.toTypedArray()
}
android.util.Log.i("PortalTls", "Enabled protocols: ${s.enabledProtocols.contentToString()}")
return s
}
}
@@ -0,0 +1,14 @@
/* AUTO-GENERATED FILE. DO NOT MODIFY.
*
* This class was automatically generated by the
* aapt tool from the resource data it found. It
* should not be modified by hand.
*/
package com.portaltv.capability;
public final class R {
public static final class style {
public static final int AppTheme=0x7f010000;
}
}
@@ -0,0 +1,52 @@
package android.util;
import java.nio.charset.StandardCharsets;
/**
* JVM test implementation of android.util.Base64 using java.util.Base64.
* Enables running Android SRP cryptographic tests on standard desktop JVM.
*/
public class Base64 {
public static final int DEFAULT = 0;
public static final int NO_PADDING = 1;
public static final int NO_WRAP = 2;
public static final int CRLF = 4;
public static final int URL_SAFE = 8;
public static final int NO_CLOSE = 16;
public static String encodeToString(byte[] input, int flags) {
return encodeToString(input, 0, input.length, flags);
}
public static String encodeToString(byte[] input, int offset, int len, int flags) {
byte[] slice;
if (offset == 0 && len == input.length) {
slice = input;
} else {
slice = new byte[len];
System.arraycopy(input, offset, slice, 0, len);
}
java.util.Base64.Encoder encoder = ((flags & URL_SAFE) != 0)
? java.util.Base64.getUrlEncoder()
: java.util.Base64.getEncoder();
if ((flags & NO_PADDING) != 0) {
encoder = encoder.withoutPadding();
}
return encoder.encodeToString(slice);
}
public static byte[] encode(byte[] input, int flags) {
return encodeToString(input, flags).getBytes(StandardCharsets.UTF_8);
}
public static byte[] decode(String str, int flags) {
java.util.Base64.Decoder decoder = ((flags & URL_SAFE) != 0)
? java.util.Base64.getUrlDecoder()
: java.util.Base64.getDecoder();
return decoder.decode(str);
}
public static byte[] decode(byte[] input, int flags) {
return decode(new String(input, StandardCharsets.UTF_8), flags);
}
}
@@ -0,0 +1,171 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
import java.security.MessageDigest
import java.security.SecureRandom
class PortalSrpChannelBindingTest : TestSuite("Cryptographic TLS Channel Binding Enforcement") {
init {
val random = SecureRandom()
fun fakeCertHash(identifier: String): ByteArray =
MessageDigest.getInstance("SHA-256").digest(identifier.toByteArray(Charsets.UTF_8))
test("Channel Binding: Legitimate TLS connection succeeds and produces valid M2") {
val pin = "718293"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val legitimateTlsCertHash = fakeCertHash("CN=PortalCam Server Cert V1")
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = legitimateTlsCertHash
)
val result = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = legitimateTlsCertHash
)
assertTrue(result is PortalSrp.VerifyResult.Success, "Legitimate TLS channel binding must succeed")
val success = result as PortalSrp.VerifyResult.Success
val m2Hex = PortalSrp.bytesToHex(success.M2)
assertTrue(client.verifyServerM2(m2Hex), "Client must accept server M2 bound to same TLS cert")
}
test("Channel Binding: Active MITM Proxy attack REJECTED by server") {
// Threat Model:
// An active MITM proxy (e.g. mitmproxy, Charles, or rogue gateway) intercepts
// the HTTPS connection. The proxy creates a separate TLS connection to the client
// using a forged CA, and another TLS connection to the Portal server using the Portal's cert.
// Client sees Cert_MITM; Server sees Cert_Real.
val pin = "998877"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val realServerTlsCertHash = fakeCertHash("CN=PortalCam Real Hardware Cert")
val mitmProxyTlsCertHash = fakeCertHash("CN=mitmproxy Fake Interceptor Cert")
// Client computes M1 bound to the MITM's TLS certificate
val m1FromClientUnderMitm = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = mitmProxyTlsCertHash
)
// MITM forwards client's (A, M1) to real server
val serverResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1FromClientUnderMitm,
tlsHash = realServerTlsCertHash // Server uses its genuine TLS cert hash
)
// Server MUST reject because M1 is bound to MITM's cert, not the server's cert!
assertTrue(
serverResult is PortalSrp.VerifyResult.Failed,
"Server MUST reject M1 generated under an active MITM proxy"
)
val failed = serverResult as PortalSrp.VerifyResult.Failed
assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message)
assertEquals(2, failed.attemptsLeft, "Failed attempt must decrement attempts counter")
}
test("Channel Binding: Single bit flip in TLS cert hash triggers authentication failure") {
val pin = "456123"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val originalCertHash = fakeCertHash("CN=PortalCam Genuine Cert")
val tamperedCertHash = originalCertHash.clone().also {
it[0] = (it[0].toInt() xor 0x01).toByte() // Flip 1 bit
}
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tamperedCertHash
)
val result = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = originalCertHash
)
assertTrue(result is PortalSrp.VerifyResult.Failed, "1-bit altered TLS hash must fail")
val failed = result as PortalSrp.VerifyResult.Failed
assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message)
}
test("Channel Binding: Client rejects server M2 if TLS channel binding is altered") {
val pin = "654987"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val clientCertHash = fakeCertHash("CN=Client Observed Cert")
val serverCertHash = fakeCertHash("CN=Client Observed Cert")
val attackerAlteredCertHash = fakeCertHash("CN=Attacker Injected Cert")
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = clientCertHash
)
val result = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, serverCertHash)
assertTrue(result is PortalSrp.VerifyResult.Success)
val success = result as PortalSrp.VerifyResult.Success
// Attacker tries to forge M2 with altered cert hash
val forgedM2 = PortalSrp.sha256(
PortalSrp.toPadded256(client.A),
client.M1!!,
client.K!!,
attackerAlteredCertHash
)
val accepted = client.verifyServerM2(PortalSrp.bytesToHex(forgedM2))
assertFalse(accepted, "Client must reject M2 bound to an altered TLS cert hash")
}
test("Channel Binding: Truncated or empty TLS cert hash fails verification") {
val pin = "123123"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val fullCertHash = fakeCertHash("CN=PortalCam Cert Full")
val emptyCertHash = ByteArray(0)
val truncatedCertHash = ByteArray(16) { fullCertHash[it] }
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = fullCertHash
)
// Server receives empty or truncated TLS hash
val resultEmpty = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, emptyCertHash)
assertTrue(resultEmpty is PortalSrp.VerifyResult.Failed)
val resultTruncated = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, truncatedCertHash)
assertTrue(resultTruncated is PortalSrp.VerifyResult.Failed)
}
}
}
@@ -0,0 +1,94 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
class PortalSrpConstantTimeTest : TestSuite("Constant-Time Comparison constantTimeEquals") {
init {
test("Constant-time: Empty byte arrays match") {
assertTrue(PortalSrp.constantTimeEquals(ByteArray(0), ByteArray(0)))
}
test("Constant-time: Single-byte matching and non-matching arrays") {
assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0x00), byteArrayOf(0x00)))
assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0x7F), byteArrayOf(0x7F)))
assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0xFF.toByte()), byteArrayOf(0xFF.toByte())))
assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x00), byteArrayOf(0x01)))
assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x7F), byteArrayOf(0x7E)))
assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x80.toByte()), byteArrayOf(0x00)))
}
test("Constant-time: 32-byte SHA-256 matching and non-matching arrays") {
val a = ByteArray(32) { (it * 7).toByte() }
val b = a.clone()
assertTrue(PortalSrp.constantTimeEquals(a, b), "Identical 32-byte arrays must match")
// Differing at index 0
val diffFirst = a.clone().also { it[0] = (it[0].toInt() xor 0x01).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diffFirst), "Difference at first byte must not match")
// Differing at middle index 16
val diffMid = a.clone().also { it[16] = (it[16].toInt() xor 0x01).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diffMid), "Difference at middle byte must not match")
// Differing at last index 31
val diffLast = a.clone().also { it[31] = (it[31].toInt() xor 0x01).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diffLast), "Difference at last byte must not match")
}
test("Constant-time: 256-byte MODP key matching and non-matching arrays") {
val a = ByteArray(256) { (it xor 0x5A).toByte() }
val b = a.clone()
assertTrue(PortalSrp.constantTimeEquals(a, b), "Identical 256-byte arrays must match")
val diff = a.clone().also { it[128] = (it[128].toInt() xor 0x80).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diff), "Differing 256-byte arrays must not match")
}
test("Constant-time: Different length byte arrays must be rejected") {
val base = ByteArray(32) { 0xAA.toByte() }
assertFalse(PortalSrp.constantTimeEquals(base, ByteArray(31) { 0xAA.toByte() }))
assertFalse(PortalSrp.constantTimeEquals(base, ByteArray(33) { 0xAA.toByte() }))
assertFalse(PortalSrp.constantTimeEquals(ByteArray(0), ByteArray(1)))
assertFalse(PortalSrp.constantTimeEquals(ByteArray(1), ByteArray(0)))
// Shared prefix but different length
val prefix = byteArrayOf(1, 2, 3)
val longer = byteArrayOf(1, 2, 3, 4)
assertFalse(PortalSrp.constantTimeEquals(prefix, longer))
assertFalse(PortalSrp.constantTimeEquals(longer, prefix))
}
test("Constant-time: Negative signed byte value edge cases") {
// In Java, byte is signed (-128 to 127).
// Verify bitwise operations properly handle negative bytes (0x80..0xFF) without sign extension bugs.
val a = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFE.toByte())
val b = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFE.toByte())
assertTrue(PortalSrp.constantTimeEquals(a, b))
val c = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFD.toByte())
assertFalse(PortalSrp.constantTimeEquals(a, c))
val d = byteArrayOf(0x00, 0xFF.toByte(), 0xFE.toByte())
assertFalse(PortalSrp.constantTimeEquals(a, d))
}
test("Constant-time: Single bit divergence test across all 256 bits of SHA-256 hash") {
val original = ByteArray(32) { 0x55.toByte() }
for (byteIdx in 0 until 32) {
for (bitIdx in 0 until 8) {
val mutated = original.clone()
mutated[byteIdx] = (mutated[byteIdx].toInt() xor (1 shl bitIdx)).toByte()
assertFalse(
PortalSrp.constantTimeEquals(original, mutated),
"Must detect 1-bit difference at byte $byteIdx, bit $bitIdx"
)
}
}
}
}
}
@@ -0,0 +1,151 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertNotNull
import com.portaltv.capability.test.Assert.assertTrue
import java.security.MessageDigest
import java.security.SecureRandom
class PortalSrpIntegrationTest : TestSuite("Portal SRP-6a End-to-End & Protocol Integration") {
init {
val random = SecureRandom()
fun sha256Hex(s: String): String =
MessageDigest.getInstance("SHA-256")
.digest(s.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
test("E2E Integration: Full PortalStreamingService SRP Handshake & Bearer Auth Flow") {
// Emulates PortalStreamingService state machine in pure Kotlin/JVM
val authTokens = mutableSetOf<String>()
val serverTlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val serverPin = "654321"
// 1. Client fetches server cert hash (GET /auth/cert)
val clientSeenCertSha256 = serverTlsCertSha256.clone()
// 2. Server initiates SRP pairing (POST /auth/srp/init)
val pairing = PortalSrp.newPairing(serverPin)
val saltHex = PortalSrp.bytesToHex(pairing.salt)
val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB))
// 3. Client initializes SRP client and computes A, M1
val client = PortalSrpClient()
val m1Hex = client.computeM1(
saltHex = saltHex,
pubBHex = bHex,
pin = serverPin,
tlsCertSha256 = clientSeenCertSha256
)
// 4. Client sends verification request (POST /auth/srp/verify)
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = serverTlsCertSha256
)
assertTrue(verifyResult is PortalSrp.VerifyResult.Success, "Verify client must succeed")
val success = verifyResult as PortalSrp.VerifyResult.Success
// Server records token hash
val tokenHash = sha256Hex(success.token)
authTokens.add(tokenHash)
// 5. Client verifies server M2
val m2Hex = PortalSrp.bytesToHex(success.M2)
assertTrue(client.verifyServerM2(m2Hex), "Client must accept server evidence M2")
// 6. Client uses bearer token on protected endpoint (GET /video.h264)
fun checkAuth(header: String?): Boolean {
if (header == null || !header.startsWith("Bearer ")) return false
val token = header.substring(7)
return authTokens.contains(sha256Hex(token))
}
assertTrue(checkAuth("Bearer ${success.token}"), "Legitimate token must grant access")
Assert.assertFalse(checkAuth("Bearer bogus-token"), "Forged token must be rejected")
Assert.assertFalse(checkAuth(null), "Missing authorization must be rejected")
}
test("E2E Integration: MITM Proxy intercepted handshake rejected at HTTP layer") {
val serverTlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val mitmProxyCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val pin = "889900"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
// Client computes M1 bound to MITM proxy's certificate
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = mitmProxyCertSha256
)
// Proxy relays M1 to server; Server checks against its real TLS cert
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = serverTlsCertSha256
)
assertTrue(verifyResult is PortalSrp.VerifyResult.Failed)
val failed = verifyResult as PortalSrp.VerifyResult.Failed
assertEquals(2, failed.attemptsLeft)
assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message)
}
test("E2E Integration: Brute-force PIN attack exhausts 3 attempts and invalidates pairing") {
val correctPin = "987654"
var activePairing: PortalSrp.ActivePairing? = PortalSrp.newPairing(correctPin)
val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
// Attacker makes 3 wrong guesses
val badGuesses = listOf("000000", "111111", "222222")
for ((index, guess) in badGuesses.withIndex()) {
val currentPairing = activePairing
assertNotNull(currentPairing, "Pairing must exist for attempt ${index + 1}")
val attackerClient = PortalSrpClient()
val attackerM1 = attackerClient.computeM1(
saltHex = PortalSrp.bytesToHex(currentPairing!!.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(currentPairing.pubB)),
pin = guess,
tlsCertSha256 = tlsCertSha256
)
val result = PortalSrp.verifyClient(
pairing = currentPairing,
A_hex = attackerClient.pubAHex,
M1_hex = attackerM1,
tlsHash = tlsCertSha256
)
assertTrue(result is PortalSrp.VerifyResult.Failed)
val failed = result as PortalSrp.VerifyResult.Failed
val expectedRemaining = 2 - index
assertEquals(expectedRemaining, failed.attemptsLeft)
if (failed.attemptsLeft <= 0) {
activePairing = null // Emulate PortalStreamingService wipeout
}
}
// Session is wiped
Assert.assertNull(activePairing, "Pairing session must be wiped after 3 failed attempts")
// 4th attempt: Even with the correct PIN, request fails because session no longer exists
val legitimateClient = PortalSrpClient()
val hasSession = activePairing != null
Assert.assertFalse(hasSession, "Cannot authenticate against wiped pairing session")
}
}
}
@@ -0,0 +1,149 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertArrayEquals
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertNotNull
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.SecureRandom
class PortalSrpMathTest : TestSuite("2048-bit RFC 5054 SRP-6a Group Parameters & Key Agreement") {
init {
test("RFC 5054 2048-bit Prime N bit-length and primality verification") {
assertEquals(2048, PortalSrp.N.bitLength(), "N must be exactly 2048 bits")
assertTrue(PortalSrp.N.testBit(0), "N must be odd")
assertTrue(PortalSrp.N.isProbablePrime(100), "N must pass Miller-Rabin primality check with certainty 100")
// Safe prime check: (N - 1) / 2 is also prime
val q = PortalSrp.N.subtract(BigInteger.ONE).divide(BigInteger.valueOf(2))
assertTrue(q.isProbablePrime(80), "Sophie Germain / safe prime (N-1)/2 must be probable prime")
}
test("RFC 5054 Generator g and Multiplier k verification") {
assertEquals(BigInteger.valueOf(2), PortalSrp.g, "Generator g must be 2")
val expectedKBytes = PortalSrp.sha256(
PortalSrp.toPadded256(PortalSrp.N),
PortalSrp.toPadded256(PortalSrp.g)
)
val expectedK = BigInteger(1, expectedKBytes)
assertEquals(expectedK, PortalSrp.k, "PortalSrp.k must equal SHA256(PAD256(N) || PAD256(g))")
}
test("SRP-6a 2048-bit mutual key agreement K_client == K_server, M1 and M2 verification") {
val random = SecureRandom()
val pin = "849201"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient(random = random)
val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB))
val saltHex = PortalSrp.bytesToHex(pairing.salt)
// Client computes M1
val m1Hex = client.computeM1(
saltHex = saltHex,
pubBHex = bHex,
pin = pin,
tlsCertSha256 = tlsCertSha256
)
// Verify Client S and Server S mathematical agreement
val A_bytes = PortalSrp.toPadded256(client.A)
val B_bytes = PortalSrp.toPadded256(pairing.pubB)
val uBytes = PortalSrp.sha256(A_bytes, B_bytes)
val u = BigInteger(1, uBytes)
val vu = pairing.v.modPow(u, PortalSrp.N)
val sServer = client.A.multiply(vu).mod(PortalSrp.N).modPow(pairing.privB, PortalSrp.N)
val kServer = PortalSrp.sha256(PortalSrp.toPadded256(sServer))
assertNotNull(client.K, "Client session key K must not be null")
assertArrayEquals(kServer, client.K!!, "Cryptographic key agreement failed: K_client != K_server")
// Server verifies M1
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = tlsCertSha256
)
assertTrue(verifyResult is PortalSrp.VerifyResult.Success, "Server must accept valid M1 from client")
val success = verifyResult as PortalSrp.VerifyResult.Success
assertTrue(success.token.isNotEmpty(), "Server must issue bearer token upon successful verification")
// Client verifies M2
val m2Hex = PortalSrp.bytesToHex(success.M2)
val m2Valid = client.verifyServerM2(m2Hex)
assertTrue(m2Valid, "Client must verify server's M2 successfully")
}
test("SRP-6a mathematical agreement across diverse PIN formats (numeric, alphanumeric, utf8)") {
val testPins = listOf(
"000000",
"999999",
"123456",
"PortalPass-2026!#$",
"Secure🔐Pässtöken-12345",
"a",
"SuperLongPINExceedingStandardLengthsForStressTestingTheSha256HashingPipeline1234567890"
)
val random = SecureRandom()
val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
for (pin in testPins) {
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient(random = random)
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tlsCertSha256
)
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = tlsCertSha256
)
assertTrue(
verifyResult is PortalSrp.VerifyResult.Success,
"Pairing must succeed for PIN: '$pin'"
)
val success = verifyResult as PortalSrp.VerifyResult.Success
assertTrue(
client.verifyServerM2(PortalSrp.bytesToHex(success.M2)),
"M2 verification must succeed for PIN: '$pin'"
)
}
}
test("Uniqueness and entropy of pairing parameters across multiple sessions") {
val pin = "555123"
val sessionCount = 50
val ids = mutableSetOf<String>()
val salts = mutableSetOf<String>()
val pubBs = mutableSetOf<BigInteger>()
for (i in 0 until sessionCount) {
val pairing = PortalSrp.newPairing(pin)
ids.add(pairing.id)
salts.add(PortalSrp.bytesToHex(pairing.salt))
pubBs.add(pairing.pubB)
}
assertEquals(sessionCount, ids.size, "All pairing IDs must be unique (sufficient entropy)")
assertEquals(sessionCount, salts.size, "All pairing salts must be unique (sufficient entropy)")
assertEquals(sessionCount, pubBs.size, "All server public keys B must be unique")
}
}
}
@@ -0,0 +1,115 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertArrayEquals
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.SecureRandom
class PortalSrpPaddingTest : TestSuite("BigInteger toPadded256 Edge Cases") {
init {
test("Padding: BigInteger.ZERO produces 256 zero bytes") {
val padded = PortalSrp.toPadded256(BigInteger.ZERO)
assertEquals(256, padded.size, "Output must be exactly 256 bytes")
val expected = ByteArray(256)
assertArrayEquals(expected, padded, "ZERO must produce all zero bytes")
}
test("Padding: BigInteger.ONE produces 255 zeros followed by 0x01") {
val padded = PortalSrp.toPadded256(BigInteger.ONE)
assertEquals(256, padded.size)
for (i in 0 until 255) {
assertEquals(0.toByte(), padded[i], "Leading bytes must be zero at index $i")
}
assertEquals(1.toByte(), padded[255], "Last byte must be 1")
}
test("Padding: Small BigInteger value (g = 2) produces 255 leading zeros") {
val padded = PortalSrp.toPadded256(PortalSrp.g)
assertEquals(256, padded.size)
for (i in 0 until 255) {
assertEquals(0.toByte(), padded[i], "Leading bytes must be zero at index $i")
}
assertEquals(2.toByte(), padded[255], "Last byte must be 2")
}
test("Padding: 128-bit (16-byte) BigInteger produces 240 leading zeros followed by 16 bytes") {
val raw16 = ByteArray(16) { (it + 1).toByte() }
val bi = BigInteger(1, raw16)
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size)
for (i in 0 until 240) {
assertEquals(0.toByte(), padded[i], "Must have 240 leading zeros")
}
for (i in 0 until 16) {
assertEquals((i + 1).toByte(), padded[240 + i])
}
}
test("Padding: Exact 256-byte positive BigInteger with MSB 0 (raw.size == 256)") {
// High byte 0x7F ensures sign bit is 0, so raw.size == 256
val raw256 = ByteArray(256) { it.toByte() }
raw256[0] = 0x7F.toByte()
val bi = BigInteger(1, raw256)
assertEquals(256, bi.toByteArray().size, "BigInteger.toByteArray() should be 256 bytes")
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size)
assertArrayEquals(raw256, padded, "Exact 256-byte number must be preserved without distortion")
}
test("Padding: Exact 2048-bit BigInteger with MSB 1 (raw.size == 257 due to Java sign byte)") {
// When MSB is 1, Java BigInteger.toByteArray() adds a leading 0x00 sign byte (257 bytes total).
// toPadded256 must strip the 0x00 sign byte and return the 256 significant bytes.
val raw256 = ByteArray(256) { 0xFF.toByte() }
val bi = BigInteger(1, raw256) // 2^2048 - 1
assertEquals(257, bi.toByteArray().size, "toByteArray() must contain 257 bytes with sign prefix")
assertEquals(0.toByte(), bi.toByteArray()[0], "First byte of toByteArray() must be sign byte 0x00")
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size, "Output must be exactly 256 bytes")
assertArrayEquals(raw256, padded, "Sign byte 0x00 must be stripped and 256 0xFF bytes retained")
}
test("Padding: RFC 5054 2048-bit prime N padding verification") {
assertEquals(257, PortalSrp.N.toByteArray().size, "N.toByteArray() has 257 bytes due to MSB=1")
val paddedN = PortalSrp.toPadded256(PortalSrp.N)
assertEquals(256, paddedN.size, "Padded N must be 256 bytes")
assertEquals(0xFF.toByte(), paddedN[0], "First byte of padded N must be 0xFF")
assertEquals(0xFF.toByte(), paddedN[255], "Last byte of padded N must be 0xFF")
}
test("Padding: Negative BigInteger representation handling") {
// Negative numbers in Java BigInteger: verify no ArrayIndexOutOfBoundsException or crashing
val negOne = BigInteger.valueOf(-1)
val paddedNegOne = PortalSrp.toPadded256(negOne)
assertEquals(256, paddedNegOne.size, "Padded negative BigInteger must be 256 bytes")
val neg128 = BigInteger.valueOf(-128)
val paddedNeg128 = PortalSrp.toPadded256(neg128)
assertEquals(256, paddedNeg128.size)
val negN = PortalSrp.N.negate()
val paddedNegN = PortalSrp.toPadded256(negN)
assertEquals(256, paddedNegN.size)
}
test("Padding: Oversized BigInteger (> 256 bytes) extracts lowest 256 bytes") {
// 258-byte number (2064 bits)
val raw258 = ByteArray(258) { (it % 256).toByte() }
raw258[0] = 0x01.toByte()
val bi = BigInteger(1, raw258)
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size, "Output must be clamped to 256 bytes")
// Verify it took the last 256 bytes
val expectedSuffix = ByteArray(256)
System.arraycopy(raw258, 2, expectedSuffix, 0, 256)
assertArrayEquals(expectedSuffix, padded, "Must extract lowest 256 bytes")
}
}
}
@@ -0,0 +1,196 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.SecureRandom
class PortalSrpRateLimitingTest : TestSuite("SRP-6a Rate Limiting & Session Invalidation") {
init {
test("Rate limiting: Exact 3-attempt lifecycle enforcement (3 -> 2 -> 1 -> 0 -> cancelled)") {
val pin = "123456"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val tlsCertSha256 = ByteArray(32) { 0x42 }
assertEquals(3, pairing.attemptsLeft, "New pairing must start with exactly 3 attempts left")
val bogusM1 = PortalSrp.bytesToHex(ByteArray(32) { 0x99.toByte() })
// Attempt 1: Failed
val res1 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res1 is PortalSrp.VerifyResult.Failed, "Attempt 1 with bogus M1 must fail")
val fail1 = res1 as PortalSrp.VerifyResult.Failed
assertEquals(2, fail1.attemptsLeft, "Attempt 1 failure must leave 2 attempts")
assertEquals(2, pairing.attemptsLeft, "Pairing object state must show 2 attempts left")
// Attempt 2: Failed
val res2 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res2 is PortalSrp.VerifyResult.Failed, "Attempt 2 with bogus M1 must fail")
val fail2 = res2 as PortalSrp.VerifyResult.Failed
assertEquals(1, fail2.attemptsLeft, "Attempt 2 failure must leave 1 attempt")
assertEquals(1, pairing.attemptsLeft, "Pairing object state must show 1 attempt left")
// Attempt 3: Failed -> 0 attempts left
val res3 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res3 is PortalSrp.VerifyResult.Failed, "Attempt 3 with bogus M1 must fail")
val fail3 = res3 as PortalSrp.VerifyResult.Failed
assertEquals(0, fail3.attemptsLeft, "Attempt 3 failure must leave 0 attempts")
assertEquals(0, pairing.attemptsLeft, "Pairing object state must show 0 attempts left")
// Subsequent Attempt 4 on exhausted session: Must be blocked immediately
val res4 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res4 is PortalSrp.VerifyResult.Failed, "Attempt on exhausted session must fail")
val fail4 = res4 as PortalSrp.VerifyResult.Failed
assertEquals(0, fail4.attemptsLeft)
assertEquals("Too many failed attempts; pairing cancelled", fail4.message)
// Even if correct credentials are now provided, exhausted pairing must remain cancelled
val validM1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tlsCertSha256
)
val resValidOnExhausted = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = validM1Hex,
tlsHash = tlsCertSha256
)
assertTrue(
resValidOnExhausted is PortalSrp.VerifyResult.Failed,
"Exhausted pairing must reject even valid credentials"
)
assertEquals(
"Too many failed attempts; pairing cancelled",
(resValidOnExhausted as PortalSrp.VerifyResult.Failed).message
)
}
test("Rate limiting: Service-level session wipeout on 0 attempts remaining") {
// Simulates PortalStreamingService session lifecycle:
// When res.attemptsLeft <= 0, pairing is wiped (pairing = null).
// A subsequent request detects pairing == null -> rejected (session not found).
var activePairing: PortalSrp.ActivePairing? = PortalSrp.newPairing("654321")
val client = PortalSrpClient()
val tlsCertSha256 = ByteArray(32) { 0x11 }
val bogusM1 = PortalSrp.bytesToHex(ByteArray(32) { 0xEE.toByte() })
fun serviceVerify(A: String, M1: String): Pair<Int, String> {
val p = activePairing ?: return Pair(400, "{\"error\":\"no_active_pairing\",\"message\":\"Call /auth/srp/init first\"}")
val res = PortalSrp.verifyClient(p, A, M1, tlsCertSha256)
return when (res) {
is PortalSrp.VerifyResult.Success -> {
activePairing = null
Pair(200, "{\"token\":\"${res.token}\"}")
}
is PortalSrp.VerifyResult.Failed -> {
if (res.attemptsLeft <= 0) {
activePairing = null // Session wiped!
}
Pair(401, "{\"error\":\"authentication_failed\",\"attemptsLeft\":${res.attemptsLeft}}")
}
}
}
// Attempt 1: 401, 2 attempts left
val (status1, body1) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(401, status1)
assertTrue(body1.contains("\"attemptsLeft\":2"))
assertTrue(activePairing != null, "Session must still exist after attempt 1")
// Attempt 2: 401, 1 attempt left
val (status2, body2) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(401, status2)
assertTrue(body2.contains("\"attemptsLeft\":1"))
assertTrue(activePairing != null, "Session must still exist after attempt 2")
// Attempt 3: 401, 0 attempts left, session wiped
val (status3, body3) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(401, status3)
assertTrue(body3.contains("\"attemptsLeft\":0"))
assertTrue(activePairing == null, "Session must be wiped after 3 failed attempts")
// Attempt 4: 400 session not found / no active pairing
val (status4, body4) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(400, status4, "Subsequent attempt after wipeout must return 400")
assertTrue(body4.contains("no_active_pairing"), "Must report session not found")
}
test("Rate limiting: Recovery on valid attempt after prior failed attempt") {
val pin = "345678"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val tlsCertSha256 = ByteArray(32) { 0x33 }
// Failed attempt 1
val bogusM1 = PortalSrp.bytesToHex(ByteArray(32))
val res1 = PortalSrp.verifyClient(pairing, client.pubAHex, bogusM1, tlsCertSha256)
assertTrue(res1 is PortalSrp.VerifyResult.Failed)
assertEquals(2, (res1 as PortalSrp.VerifyResult.Failed).attemptsLeft)
// Successful attempt 2 with correct PIN and M1
val validM1 = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tlsCertSha256
)
val res2 = PortalSrp.verifyClient(pairing, client.pubAHex, validM1, tlsCertSha256)
assertTrue(res2 is PortalSrp.VerifyResult.Success, "Valid attempt 2 after 1 failure must succeed")
}
test("Rate limiting: Session expiration blocks verification") {
val pin = "112233"
val expiredPairing = PortalSrp.ActivePairing(
id = "expired-session-id",
pin = pin,
salt = ByteArray(16),
v = BigInteger.valueOf(3),
privB = BigInteger.valueOf(4),
pubB = BigInteger.valueOf(5),
expiresAt = System.currentTimeMillis() - 5000L, // Expired 5 seconds ago
attemptsLeft = 3
)
val client = PortalSrpClient()
val dummyTls = ByteArray(32)
val res = PortalSrp.verifyClient(
pairing = expiredPairing,
A_hex = client.pubAHex,
M1_hex = PortalSrp.bytesToHex(ByteArray(32)),
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed)
val fail = res as PortalSrp.VerifyResult.Failed
assertEquals(0, fail.attemptsLeft)
assertEquals("Pairing session expired", fail.message)
}
}
}
@@ -0,0 +1,190 @@
package com.portaltv.capability.test
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.MessageDigest
class PortalSrpRfc5054Test : TestSuite("RFC 5054 Appendix B Test Vectors") {
companion object {
private fun cleanHex(s: String) = s.replace("\\s+".toRegex(), "").lowercase()
// 1024-bit prime from RFC 5054 Appendix A
val N_HEX = cleanHex(
"""
EEAF0AB9 ADB38DD6 9C33F80A FA8FC5E8 60726187 75FF3C0B 9EA2314C
9C256576 D674DF74 96EA81D3 383B4813 D692C6E0 E0D5D8E2 50B98BE4
8E495C1D 6089DAD1 5DC7D7B4 6154D6B6 CE8EF4AD 69B15D49 82559B29
7BCF1885 C529F566 660E57EC 68EDBC3C 05726CC0 2FD4CBF4 976EAA9A
FD5138FE 8376435B 9FC61D2F C0EB06E3
"""
)
val N = BigInteger(N_HEX, 16)
val g = BigInteger.valueOf(2)
const val I = "alice"
const val P = "password123"
val SALT_HEX = cleanHex("BEB25379 D1A8581E B5A72767 3A2441EE")
val K_EXPECTED = cleanHex("7556AA04 5AEF2CDD 07ABAF0F 665C3E81 8913186F")
val X_EXPECTED = cleanHex("94B7555A ABE9127C C58CCF49 93DB6CF8 4D16C124")
val V_EXPECTED = cleanHex(
"""
7E273DE8 696FFC4F 4E337D05 B4B375BE B0DDE156 9E8FA00A 9886D812
9BADA1F1 822223CA 1A605B53 0E379BA4 729FDC59 F105B478 7E5186F5
C671085A 1447B52A 48CF1970 B4FB6F84 00BBF4CE BFBB1681 52E08AB5
EA53D15C 1AFF87B2 B9DA6E04 E058AD51 CC72BFC9 033B564E 26480D78
E955A5E2 9E7AB245 DB2BE315 E2099AFB
"""
)
val A_PRIV_HEX = cleanHex("60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD DA2D4393")
val B_PRIV_HEX = cleanHex("E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20")
val A_PUB_EXPECTED = cleanHex(
"""
61D5E490 F6F1B795 47B0704C 436F523D D0E560F0 C64115BB 72557EC4
4352E890 3211C046 92272D8B 2D1A5358 A2CF1B6E 0BFCF99F 921530EC
8E393561 79EAE45E 42BA92AE ACED8251 71E1E8B9 AF6D9C03 E1327F44
BE087EF0 6530E69F 66615261 EEF54073 CA11CF58 58F0EDFD FE15EFEA
B349EF5D 76988A36 72FAC47B 0769447B
"""
)
val B_PUB_EXPECTED = cleanHex(
"""
BD0C6151 2C692C0C B6D041FA 01BB152D 4916A1E7 7AF46AE1 05393011
BAF38964 DC46A067 0DD125B9 5A981652 236F99D9 B681CBF8 7837EC99
6C6DA044 53728610 D0C6DDB5 8B318885 D7D82C7F 8DEB75CE 7BD4FBAA
37089E6F 9C6059F3 88838E7A 00030B33 1EB76840 910440B1 B27AAEAE
EB4012B7 D7665238 A8E3FB00 4B117B58
"""
)
val U_EXPECTED = cleanHex("CE38B959 3487DA98 554ED47D 70A7AE5F 462EF019")
val S_EXPECTED = cleanHex(
"""
B0DC82BA BCF30674 AE450C02 87745E79 90A3381F 63B387AA F271A10D
233861E3 59B48220 F7C4693C 9AE12B0A 6F67809F 0876E2D0 13800D6C
41BB59B6 D5979B5C 00A172B4 A2A5903A 0BDCAF8A 709585EB 2AFAFA8F
3499B200 210DCC1F 10EB3394 3CD67FC8 8A2F39A4 BE5BEC4E C0A3212D
C346D7E4 74B29EDE 8A469FFE CA686E5A
"""
)
fun sha1(vararg parts: ByteArray): ByteArray {
val md = MessageDigest.getInstance("SHA-1")
for (p in parts) md.update(p)
return md.digest()
}
fun toPadded128(bi: BigInteger): ByteArray {
val raw = bi.toByteArray()
val result = ByteArray(128)
if (raw.size > 128) {
System.arraycopy(raw, raw.size - 128, result, 0, 128)
} else {
System.arraycopy(raw, 0, result, 128 - raw.size, raw.size)
}
return result
}
fun bytesToHex(bytes: ByteArray): String =
bytes.joinToString("") { "%02x".format(it) }
fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim()
val len = clean.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] = ((Character.digit(clean[i], 16) shl 4) + Character.digit(clean[i + 1], 16)).toByte()
i += 2
}
return data
}
}
init {
test("RFC 5054 - Multiplier k = H(PAD(N) || PAD(g)) verification") {
val nBytes = toPadded128(N)
val gBytes = toPadded128(g)
val kBytes = sha1(nBytes, gBytes)
val kHex = bytesToHex(kBytes)
assertEquals(K_EXPECTED, kHex, "Multiplier k must match RFC 5054 test vector")
}
test("RFC 5054 - Password hash x = H(s || H(I || ':' || P)) verification") {
val inner = sha1("$I:$P".toByteArray(Charsets.UTF_8))
val salt = hexToBytes(SALT_HEX)
val xBytes = sha1(salt, inner)
val xHex = bytesToHex(xBytes)
assertEquals(X_EXPECTED, xHex, "Password hash x must match RFC 5054 test vector")
}
test("RFC 5054 - Verifier v = g^x mod N verification") {
val x = BigInteger(X_EXPECTED, 16)
val v = g.modPow(x, N)
val vHex = bytesToHex(toPadded128(v))
assertEquals(V_EXPECTED, vHex, "Verifier v must match RFC 5054 test vector")
}
test("RFC 5054 - Client public key A = g^a mod N verification") {
val a = BigInteger(A_PRIV_HEX, 16)
val A = g.modPow(a, N)
val aHex = bytesToHex(toPadded128(A))
assertEquals(A_PUB_EXPECTED, aHex, "Public key A must match RFC 5054 test vector")
}
test("RFC 5054 - Server public key B = (k*v + g^b) mod N verification") {
val k = BigInteger(1, hexToBytes(K_EXPECTED))
val v = BigInteger(1, hexToBytes(V_EXPECTED))
val b = BigInteger(B_PRIV_HEX, 16)
val gb = g.modPow(b, N)
val B = k.multiply(v).add(gb).mod(N)
val bHex = bytesToHex(toPadded128(B))
assertEquals(B_PUB_EXPECTED, bHex, "Public key B must match RFC 5054 test vector")
}
test("RFC 5054 - Scrambler u = H(PAD(A) || PAD(B)) verification") {
val A = BigInteger(A_PUB_EXPECTED, 16)
val B = BigInteger(B_PUB_EXPECTED, 16)
val uBytes = sha1(toPadded128(A), toPadded128(B))
val uHex = bytesToHex(uBytes)
assertEquals(U_EXPECTED, uHex, "Scrambler u must match RFC 5054 test vector")
}
test("RFC 5054 - Premaster secret S client/server agreement and test vector match") {
val k = BigInteger(1, hexToBytes(K_EXPECTED))
val v = BigInteger(1, hexToBytes(V_EXPECTED))
val x = BigInteger(1, hexToBytes(X_EXPECTED))
val a = BigInteger(A_PRIV_HEX, 16)
val b = BigInteger(B_PRIV_HEX, 16)
val A = BigInteger(A_PUB_EXPECTED, 16)
val B = BigInteger(B_PUB_EXPECTED, 16)
val u = BigInteger(1, hexToBytes(U_EXPECTED))
// Client S = (B - k * (g^x mod N) mod N) ^ (a + u * x) mod N
val gx = g.modPow(x, N)
val kgx = k.multiply(gx).mod(N)
val clientBase = B.subtract(kgx).mod(N)
val clientExp = a.add(u.multiply(x))
val sClient = clientBase.modPow(clientExp, N)
// Server S = (A * v^u mod N) ^ b mod N
val vu = v.modPow(u, N)
val serverBase = A.multiply(vu).mod(N)
val sServer = serverBase.modPow(b, N)
val sClientHex = bytesToHex(toPadded128(sClient))
val sServerHex = bytesToHex(toPadded128(sServer))
assertEquals(S_EXPECTED, sClientHex, "Client S must match RFC 5054 premaster secret")
assertEquals(S_EXPECTED, sServerHex, "Server S must match RFC 5054 premaster secret")
assertEquals(sClient, sServer, "Client and Server premaster secret S must be identical")
}
}
}
@@ -0,0 +1,146 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertFailsWith
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
class PortalSrpSafetyTest : TestSuite("SRP-6a Safety Checks (A mod N != 0, B mod N != 0, u != 0)") {
init {
test("Safety: Reject A mod N == 0 in validator helper") {
assertFalse(PortalSrp.isValidPublicA(BigInteger.ZERO), "A = 0 must be rejected")
assertFalse(PortalSrp.isValidPublicA(PortalSrp.N), "A = N must be rejected (A mod N == 0)")
assertFalse(PortalSrp.isValidPublicA(PortalSrp.N.multiply(BigInteger.valueOf(2))), "A = 2N must be rejected")
assertFalse(PortalSrp.isValidPublicA(PortalSrp.N.multiply(BigInteger.valueOf(99))), "A = 99N must be rejected")
assertTrue(PortalSrp.isValidPublicA(BigInteger.valueOf(2)), "A = 2 is valid")
assertTrue(PortalSrp.isValidPublicA(PortalSrp.N.subtract(BigInteger.ONE)), "A = N-1 is valid")
}
test("Safety: Server verifyClient rejects A = 0") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val res = PortalSrp.verifyClient(
pairing = pairing,
A_hex = "00",
M1_hex = dummyM1,
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = 0")
val failed = res as PortalSrp.VerifyResult.Failed
assertEquals("Invalid public key A", failed.message)
assertEquals(2, failed.attemptsLeft, "Failed attempt must decrement attempts counter")
}
test("Safety: Server verifyClient rejects A == N (A mod N == 0)") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val nHex = PortalSrp.N.toString(16)
val res = PortalSrp.verifyClient(
pairing = pairing,
A_hex = nHex,
M1_hex = dummyM1,
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = N")
val failed = res as PortalSrp.VerifyResult.Failed
assertEquals("Invalid public key A", failed.message)
assertEquals(2, failed.attemptsLeft)
}
test("Safety: Server verifyClient rejects A == 2N (A mod N == 0)") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val twoNHex = PortalSrp.N.multiply(BigInteger.valueOf(2)).toString(16)
val res = PortalSrp.verifyClient(
pairing = pairing,
A_hex = twoNHex,
M1_hex = dummyM1,
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = 2N")
val failed = res as PortalSrp.VerifyResult.Failed
assertEquals("Invalid public key A", failed.message)
}
test("Safety: Server verifyClient rejects malformed or non-hex A") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val res1 = PortalSrp.verifyClient(pairing, "not-valid-hex", dummyM1, dummyTls)
assertTrue(res1 is PortalSrp.VerifyResult.Failed)
assertEquals("Invalid client public key format", (res1 as PortalSrp.VerifyResult.Failed).message)
val res2 = PortalSrp.verifyClient(pairing, "", dummyM1, dummyTls)
assertTrue(res2 is PortalSrp.VerifyResult.Failed)
assertEquals("Invalid client public key format", (res2 as PortalSrp.VerifyResult.Failed).message)
}
test("Safety: Reject B mod N == 0 in validator helper") {
assertFalse(PortalSrp.isValidPublicB(BigInteger.ZERO), "B = 0 must be rejected")
assertFalse(PortalSrp.isValidPublicB(PortalSrp.N), "B = N must be rejected (B mod N == 0)")
assertFalse(PortalSrp.isValidPublicB(PortalSrp.N.multiply(BigInteger.valueOf(3))), "B = 3N must be rejected")
assertTrue(PortalSrp.isValidPublicB(BigInteger.valueOf(2)), "B = 2 is valid")
assertTrue(PortalSrp.isValidPublicB(PortalSrp.N.subtract(BigInteger.ONE)), "B = N-1 is valid")
}
test("Safety: Client rejects server B == 0 (B mod N == 0)") {
val client = PortalSrpClient()
val dummySaltHex = PortalSrp.bytesToHex(ByteArray(16))
val dummyTls = ByteArray(32)
val ex = assertFailsWith<IllegalArgumentException>("Client must reject B = 0") {
client.computeM1(
saltHex = dummySaltHex,
pubBHex = "00",
pin = "123456",
tlsCertSha256 = dummyTls
)
}
assertTrue(ex.message!!.contains("B % N == 0"), "Exception message should explain safety rejection")
}
test("Safety: Client rejects server B == N (B mod N == 0)") {
val client = PortalSrpClient()
val dummySaltHex = PortalSrp.bytesToHex(ByteArray(16))
val dummyTls = ByteArray(32)
val nHex = PortalSrp.N.toString(16)
val ex = assertFailsWith<IllegalArgumentException>("Client must reject B = N") {
client.computeM1(
saltHex = dummySaltHex,
pubBHex = nHex,
pin = "123456",
tlsCertSha256 = dummyTls
)
}
assertTrue(ex.message!!.contains("B % N == 0"), "Exception message should explain safety rejection")
}
test("Safety: Server newPairing always generates valid B mod N != 0") {
for (i in 0 until 20) {
val p = PortalSrp.newPairing("777888")
assertTrue(PortalSrp.isValidPublicB(p.pubB), "Server generated B must satisfy B mod N != 0")
}
}
test("Safety: Reject u == 0 in validator helper") {
assertFalse(PortalSrp.isValidScrambler(BigInteger.ZERO), "u = 0 must be rejected")
assertTrue(PortalSrp.isValidScrambler(BigInteger.ONE), "u = 1 is valid")
assertTrue(PortalSrp.isValidScrambler(BigInteger.valueOf(42)), "u = 42 is valid")
}
}
}
@@ -0,0 +1,65 @@
package com.portaltv.capability.test
class AssertionException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
object Assert {
fun assertTrue(condition: Boolean, message: String = "Expected condition to be true") {
if (!condition) throw AssertionException(message)
}
fun assertFalse(condition: Boolean, message: String = "Expected condition to be false") {
if (condition) throw AssertionException(message)
}
fun assertEquals(expected: Any?, actual: Any?, message: String = "") {
if (expected != actual) {
val prefix = if (message.isNotEmpty()) "$message: " else ""
throw AssertionException("${prefix}Expected <$expected> but got <$actual>")
}
}
fun assertNotEquals(unexpected: Any?, actual: Any?, message: String = "") {
if (unexpected == actual) {
val prefix = if (message.isNotEmpty()) "$message: " else ""
throw AssertionException("${prefix}Expected value to differ from <$unexpected>")
}
}
fun assertArrayEquals(expected: ByteArray, actual: ByteArray, message: String = "") {
if (!expected.contentEquals(actual)) {
val prefix = if (message.isNotEmpty()) "$message: " else ""
val expHex = expected.joinToString("") { "%02x".format(it) }
val actHex = actual.joinToString("") { "%02x".format(it) }
throw AssertionException("${prefix}Byte arrays do not match.\nExpected: $expHex\nActual: $actHex")
}
}
fun assertNotNull(actual: Any?, message: String = "Expected non-null value") {
if (actual == null) throw AssertionException(message)
}
fun assertNull(actual: Any?, message: String = "Expected null value") {
if (actual != null) throw AssertionException("$message (was: $actual)")
}
inline fun <reified T : Throwable> assertFailsWith(message: String = "", block: () -> Unit): T {
try {
block()
} catch (t: Throwable) {
if (t is T) return t
throw AssertionException("Expected exception ${T::class.java.simpleName} but caught ${t::class.java.simpleName}: ${t.message}", t)
}
val prefix = if (message.isNotEmpty()) "$message: " else ""
throw AssertionException("${prefix}Expected exception ${T::class.java.simpleName} was not thrown")
}
}
data class TestCase(val name: String, val block: () -> Unit)
abstract class TestSuite(val name: String) {
val cases = mutableListOf<TestCase>()
fun test(name: String, block: () -> Unit) {
cases += TestCase(name, block)
}
}

Some files were not shown because too many files have changed in this diff Show More