Files
Portal-Camera/mac/PortalCam/PortalCam/ContentView.swift
T
2026-09-13 12:15:36 -07:00

805 lines
28 KiB
Swift

//
// 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())
}