805 lines
28 KiB
Swift
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())
|
|
}
|