diff --git a/RemoteCam/CameraLink.swift b/RemoteCam/CameraLink.swift new file mode 100644 index 0000000..2d3128b --- /dev/null +++ b/RemoteCam/CameraLink.swift @@ -0,0 +1,63 @@ +// +// CameraLink.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import Foundation +import MPCCompat +import Stormo + +/// One camera in a multicam director session. The director holds one of these +/// per connected camera, keyed by `MCPeerID`; it is the multicam analog of the +/// single link + single `FrameStreamReceiver` that `SessionCoordinator` holds +/// for a 1:1 monitor. +/// +/// A reference type, not a struct: it owns a `FrameStreamReceiver` (a class +/// with a running decode timer) and the `MulticamController` actor mutates it +/// in place as frames, capabilities and clock samples arrive — a value type +/// would force a dictionary read-modify-write on every frame. +final class CameraLink { + + enum Status: Equatable { + /// The session is up and frames are expected. + case linked + /// The link dropped; the director is re-browsing to invite it back. + /// The tile stays on screen (last frame frozen) rather than vanishing. + case reconnecting + /// The peer is gone for good (removed by the user, or version-refused). + case failed + } + + let peerID: MCPeerID + let displayName: String + var status: Status = .linked + + /// The most recent capabilities the camera advertised, or nil until the + /// first exchange completes. `supportsMulticam` gates the multicam-only + /// wire messages (clock sync now; scheduled capture in later PRs). + var capabilities: RemoteCmd.CameraCapabilitiesResp? + var supportsMulticam: Bool { capabilities?.supportsMulticam ?? false } + + /// Rolling clock-offset estimate for this camera, fed by ClockSyncPong. + /// Stored here so a future synced capture can schedule on the camera's own + /// clock (PR4); PR3 only measures and surfaces it. + var clockEstimator = ClockOffsetEstimator() + var latestOffset: ClockOffsetSample? { clockEstimator.best } + + /// Monitor side: this lane has produced at least one VP9 frame, proving the + /// camera speaks VP9 — the gate for sending it `RequestKeyframe` (mirrors + /// `SessionCoordinator.monitorReceivedVP9Frame`, but per camera). + var sawVP9 = false + + /// This camera's own preview decoder + stall watchdog. Frames tagged with + /// this peer's id are fed here; its `onImage` drives exactly this lane's + /// tile, so a frame from another camera never touches it. + let receiver = FrameStreamReceiver() + + init(peerID: MCPeerID) { + self.peerID = peerID + self.displayName = peerID.displayName + } +} diff --git a/RemoteCam/DeviceScannerView.swift b/RemoteCam/DeviceScannerView.swift index c7c86c7..570a20c 100644 --- a/RemoteCam/DeviceScannerView.swift +++ b/RemoteCam/DeviceScannerView.swift @@ -14,6 +14,9 @@ struct DeviceScannerView: View { let onShareApp: () -> Void let onOpenSettings: () -> Void let onHelp: () -> Void + /// Multicam only: begin a director session with the cameras collected so + /// far. Nil in the single-camera build (flag off), where it never shows. + var onStartMulticam: (() -> Void)? = nil /// Peer-link state; the reconnect overlay is a function of it. @ObservedObject var peerLink: PeerLinkStatus = .shared @@ -34,10 +37,34 @@ struct DeviceScannerView: View { connectingOverlay } + if viewModel.multicamCollectedCount >= 1, let onStartMulticam { + startMulticamButton(onStartMulticam) + } + PeerLinkOverlay(status: peerLink) } } + /// Floating "Start (N)" for the multicam collecting flow. One camera starts + /// the classic monitor; two or more starts the director grid. + private func startMulticamButton(_ action: @escaping () -> Void) -> some View { + VStack { + Spacer() + Button(action: action) { + Text(String(format: NSLocalizedString("Start (%d)", comment: "start multicam with N cameras"), + viewModel.multicamCollectedCount)) + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(AppTheme.accent) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + } + // MARK: - Peer List private var peerList: some View { diff --git a/RemoteCam/DeviceScannerViewController.swift b/RemoteCam/DeviceScannerViewController.swift index 720e08b..f5f6d36 100644 --- a/RemoteCam/DeviceScannerViewController.swift +++ b/RemoteCam/DeviceScannerViewController.swift @@ -109,6 +109,11 @@ public class DeviceScannerViewController: UIViewController { remoteCamSession.setFrameSender(frameSender) self.remoteCamSession ! SetScannerLobby(lobby: self) scannerViewModel.role = role + // Multicam director collecting: only the monitor role, only behind the + // flag. Off, the coordinator's scanning path is byte-identical. + if FeatureFlags.ENABLE_MULTICAM && role == .monitor { + remoteCamSession ! UICmd.SetMulticamCollecting(on: true) + } // The reconnect overlay's only action, routed like every other UI // command; the overlay itself is pure state (PeerLinkStatus). PeerLinkStatus.shared.onCancel = { [weak self] in @@ -188,7 +193,10 @@ public class DeviceScannerViewController: UIViewController { }, onHelp: { [weak self] in self?.showHelpModal() - } + }, + onStartMulticam: (FeatureFlags.ENABLE_MULTICAM && role == .monitor) + ? { [weak self] in self?.startMulticamSession() } + : nil ) swiftUIHostingController = embedSwiftUIView(scannerView) @@ -331,6 +339,29 @@ public class DeviceScannerViewController: UIViewController { } } + /// Multicam "Start (N)": one camera runs the classic 1:1 monitor + /// (unchanged); two or more hands the live transport to a + /// `MulticamController` and pushes the director screen. + private func startMulticamSession() { + Task { @MainActor in + // Two or more cameras: hand the live transport to a director. + if let handoff = await remoteCamSession.detachTransportForMulticam() { + let controller = MulticamController() + await controller.install(transport: handoff.transport, + initialPeers: handoff.peers, + mode: .photo) + let directorVC = MulticamViewController(controller: controller) + navigationController?.pushViewController(directorVC, animated: true) + return + } + // Exactly one camera: promote it to a normal session and run the + // classic 1:1 monitor, unchanged. + if await remoteCamSession.promoteSingleCollectedToConnected() { + goToRole() + } + } + } + func goToAppSettings() { #if targetEnvironment(macCatalyst) // Local-network permission lives in System Settings on the Mac. @@ -377,6 +408,11 @@ extension DeviceScannerViewController: ScannerLobby { navigationController?.popToViewController(self, animated: true) } + /// Multicam collecting: surface the running count so "Start (N)" appears. + func didCollectMulticamCameras(_ peers: [MCPeerID]) { + scannerViewModel.multicamCollectedCount = peers.count + } + func presentScanningError() { let alert = UIAlertController( title: NSLocalizedString("Scanning Error", comment: ""), diff --git a/RemoteCam/DeviceScannerViewModel.swift b/RemoteCam/DeviceScannerViewModel.swift index 687bfa0..29e494a 100644 --- a/RemoteCam/DeviceScannerViewModel.swift +++ b/RemoteCam/DeviceScannerViewModel.swift @@ -53,6 +53,9 @@ final class DeviceScannerViewModel: ObservableObject { @Published var hasScanningError: Bool = false @Published var isConnecting: Bool = false @Published var hasConnectionError: Bool = false + /// Multicam director collecting: how many cameras are connected so far. + /// Drives the "Start (N)" affordance; stays 0 in the single-camera build. + @Published var multicamCollectedCount: Int = 0 /// When the current scan began. Not @Published: the view samples it on a /// TimelineView clock, so publishing would only cause redundant redraws. diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift new file mode 100644 index 0000000..0b9d82d --- /dev/null +++ b/RemoteCam/MulticamController.swift @@ -0,0 +1,495 @@ +// +// MulticamController.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +// swiftlint:disable cyclomatic_complexity function_body_length + +import Foundation +import MPCCompat +import Stormo +import UIKit + +/// The director's aggregate posture across all cameras. One shutter drives +/// them all, so the capture states are aggregate, not per-camera. PR3 only +/// needs `.monitoring`; the capture cases arrive with synced photo/video. +enum MulticamState: Equatable { + case monitoring(mode: MonitorMode) +} + +/// A UI-facing snapshot of one lane. Deliberately a value type carrying only +/// what the chrome/tile needs, so the actor never hands the UI a live +/// reference into its own state. +struct MulticamLaneInfo: Equatable { + let peerID: MCPeerID + let displayName: String + let status: CameraLink.Status + let isFocused: Bool + /// Clock-offset estimate in ms (nil until the first pong), for a future + /// sync-quality indicator; unused by PR3's UI beyond diagnostics. + let clockOffsetMillis: Int64? +} + +/// The main-actor bridge from the controller to the multicam screen — the +/// multicam analog of `MonitorDisplay`. Low-frequency lane changes go through +/// `applyLanes`; the ~20fps preview stream goes through `receiveFrame`, which +/// the view controller routes to exactly one lane's decoder so a frame from +/// camera B never re-renders camera A. +protocol MulticamDisplay: AnyObject { + func applyLanes(_ lanes: [MulticamLaneInfo]) + func receiveFrame(_ frame: RemoteCmd.OnFrame) + func exitMulticam() +} + +/// Director side of a multicam session: one controller, several cameras. +/// +/// A sibling of `SessionCoordinator`, not a replacement — `SessionCoordinator` +/// stays the sole brain for the camera role and for 1:1 monitoring, both +/// byte-identical to before. This actor is reached only when the director +/// starts a multicam session (≥2 cameras, behind `ENABLE_MULTICAM`). The +/// camera side is unchanged: a camera cannot tell a multicam director from a +/// single monitor. +/// +/// Mirrors `SessionCoordinator`'s concurrency shape: a FIFO inbox fed by +/// `tell(_:)`, nonisolated transport-delegate callbacks that enqueue, and a +/// lock-boxed transport mirror for the sends that must not queue behind +/// state-machine work (frame acks, clock-sync answers). +public actor MulticamController { + + // MARK: Inbox (mirrors SessionCoordinator) + + private nonisolated let inboxContinuation: Locked.Continuation?> = Locked(nil) + private nonisolated let pendingCount = Locked(0) + + public init() { + var continuation: AsyncStream.Continuation! + let stream = AsyncStream(bufferingPolicy: .unbounded) { continuation = $0 } + self.inboxContinuation.value = continuation + let pending = pendingCount + Task { [weak self] in + for await msg in stream { + guard let self else { break } + await self.handle(msg) + pending.mutate { $0 -= 1 } + } + } + } + + public nonisolated func tell(_ msg: Message) { + pendingCount.mutate { $0 += 1 } + inboxContinuation.value?.yield(msg) + } + + /// Test support: suspend until every enqueued message is processed. + public nonisolated func waitForIdle() async { + while pendingCount.value > 0 { + await Task.yield() + } + } + + public nonisolated func stop() { + clockSyncTask.value?.cancel() + transportShared.value?.stopSession() + inboxContinuation.value?.finish() + } + + // MARK: Transport + + private var multipeerService: (any MultipeerServiceProtocol)? + private let transportShared = Locked<(any MultipeerServiceProtocol)?>(nil) + private nonisolated let clockSyncTask = Locked?>(nil) + + // MARK: State + + private var state: MulticamState = .monitoring(mode: .photo) + /// Insertion-ordered peer ids, so the strip/grid order is stable as lanes + /// come and go. + private var order: [MCPeerID] = [] + private var links: [MCPeerID: CameraLink] = [:] + private var focusedPeer: MCPeerID? + + private weak var display: MulticamDisplay? + + /// The interval between clock-offset refreshes per camera. + private let clockSyncInterval: TimeInterval = 30 + /// How far ahead a re-invite waits before re-browsing a dropped camera. + private var reconnectRetryDelay: TimeInterval = 3 + private let reconnectInviteTimeout: TimeInterval = 10 + + // MARK: Test / wiring seams + + func setDisplay(_ display: MulticamDisplay) { + self.display = display + // The display is wired after `install` (the screen is pushed only once + // the handoff is done), so replay the current lanes now — otherwise the + // first snapshot, emitted during install, reaches no one. + publishLanes() + } + func setReconnectRetryDelay(_ delay: TimeInterval) { reconnectRetryDelay = delay } + + /// Test support. + func lanesForTesting() -> [MulticamLaneInfo] { laneSnapshot() } + func focusedPeerForTesting() -> MCPeerID? { focusedPeer } + func statusForTesting(_ peer: MCPeerID) -> CameraLink.Status? { links[peer]?.status } + func offsetForTesting(_ peer: MCPeerID) -> Int64? { links[peer]?.latestOffset?.offsetMillis } + + // MARK: - Handoff + + /// Take over a transport the scanner already connected to `initialPeers`. + /// Becomes the transport delegate (the scanner's `SessionCoordinator` + /// stops receiving callbacks from here on), seeds a lane per peer, kicks + /// the capability handshake + clock sync, and keeps browsing so more + /// cameras can be invited later. + func install(transport: any MultipeerServiceProtocol, + initialPeers: [MCPeerID], + mode: MonitorMode) { + multipeerService = transport + transportShared.value = transport + transport.delegate = self + state = .monitoring(mode: mode) + + for peer in initialPeers where links[peer] == nil { + order.append(peer) + links[peer] = CameraLink(peerID: peer) + } + focusedPeer = focusedPeer ?? order.first + + // Keep discovering so the in-session "add camera" flow (PR7) has a + // live peer list; a director that stopped browsing on connect could + // never grow the rig. + transport.startBrowsingOnly() + + for peer in order { beginHandshake(with: peer) } + startClockSyncLoop() + publishLanes() + } + + /// The initial per-camera handshake: announce the director role (carries + /// our version so the camera can gate us) and ask for capabilities. The + /// camera answers with `CameraCapabilitiesResp`, at which point the lane + /// goes live and its frame pump starts. + private func beginHandshake(with peer: MCPeerID) { + sendTo(peer, RemoteCmd.PeerBecameMonitor.createWithDefaults()) + sendTo(peer, RemoteCmd.RequestCameraCapabilities()) + // Prime the stream: the camera streams once it holds a frame credit. + sendTo(peer, RemoteCmd.RequestFrame(sender: nil)) + } + + // MARK: - Message handling + + func handle(_ msg: Message) async { + switch msg { + case let connected as OnConnectToDevice: + handlePeerConnected(connected.peer) + + case let disconnected as DisconnectPeer: + if let peer = disconnected.peer { handlePeerDisconnected(peer) } + + case let found as UICmd.BrowserFoundPeer: + handleBrowserFound(found.peer) + + case let routed as RoutedMessage: + await handleRouted(routed.message, from: routed.peer) + + case let frame as RemoteCmd.OnFrame: + handleFrame(frame) + + case let measured as ClockPongMeasured: + storePong(measured.pong, t3: measured.t3, from: measured.peer) + + case is UICmd.AppForegrounded: + // Clocks freeze while backgrounded; the estimates are stale. Drop + // them and re-measure, mirroring the frame path's foreground rearm. + for link in links.values { link.clockEstimator.reset() } + pingClocks() + + default: + break + } + } + + /// A camera-addressed message that arrived with its source peer (Seam A). + private func handleRouted(_ message: Message, from peer: MCPeerID) async { + guard let link = links[peer] else { return } + + switch message { + case let became as RemoteCmd.PeerBecameCamera: + // Same-major-or-refuse, per camera. A refused camera is dropped + // from the rig rather than silently streaming a peer we can't + // fully drive. + if !isPeerCompatible(became) { + link.status = .failed + publishLanes() + } + + case let caps as RemoteCmd.CameraCapabilitiesResp: + link.capabilities = caps + if link.status != .failed { link.status = .linked } + publishLanes() + // A multicam-capable camera gets an immediate clock probe so its + // offset is ready well before the first synced capture (PR4). + if link.supportsMulticam { + sendTo(peer, RemoteCmd.ClockSyncPing(t0Millis: SyncClock.nowMillis())) + } + + default: + // Per-camera command responses (zoom/lens/flash/torch acks) update + // only the focused lane's controls, wired to the UI in a later PR; + // PR3 surfaces frames + status, so these are accepted and ignored. + break + } + } + + private func handlePeerConnected(_ peer: MCPeerID) { + if let existing = links[peer] { + // A reconnecting lane came back — rehandshake and relight it. + existing.status = .linked + } else { + order.append(peer) + links[peer] = CameraLink(peerID: peer) + } + focusedPeer = focusedPeer ?? peer + beginHandshake(with: peer) + publishLanes() + } + + private func handlePeerDisconnected(_ peer: MCPeerID) { + guard let link = links[peer] else { return } + // Degrade the tile, keep the rest of the rig recording/monitoring. The + // controller stays browsing, so `browserDidFindPeer` re-invites. + link.status = .reconnecting + publishLanes() + armReconnect(peer) + } + + private func handleBrowserFound(_ peer: MCPeerID) { + // Re-invite only a camera we are actively missing; a fresh peer is a + // job for the add-camera flow (PR7), not an auto-join. + guard let link = links[peer], link.status == .reconnecting else { return } + multipeerService?.invitePeer(peer, timeout: reconnectInviteTimeout) + } + + private func armReconnect(_ peer: MCPeerID) { + let delay = reconnectRetryDelay + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard let self else { return } + await self.reBrowseIfStillMissing(peer) + } + } + + private func reBrowseIfStillMissing(_ peer: MCPeerID) { + guard links[peer]?.status == .reconnecting else { return } + // Rebuild the browse so the lost peer is re-reported at a live address. + multipeerService?.startBrowsingOnly() + } + + private func handleFrame(_ frame: RemoteCmd.OnFrame) { + guard let link = links[frame.peerId] else { return } + if frame.codec == .vp9 { link.sawVP9 = true } + // Route to exactly this lane's decoder (rendering isolation), then ack + // only this camera so its credit window advances and no other camera + // sends on a frame it didn't produce (Seam B). + display?.receiveFrame(frame) + sendTo(frame.peerId, RemoteCmd.RequestFrame(sender: nil)) + } + + // MARK: - Per-camera controls (focused peer only) + + /// PR3 wires these to the focused lane; capture (all-camera) lands in PR4. + func setZoom(_ factor: CGFloat) { focusedSend(RemoteCmd.SetZoom(zoomFactor: factor)) } + func toggleTorch() { focusedSend(RemoteCmd.ToggleTorch()) } + + func focusAtPoint(x: Float, y: Float) { + guard let peer = focusedPeer, links[peer]?.capabilities?.supportsFocusPoint == true else { return } + sendTo(peer, RemoteCmd.FocusAtPoint(x: x, y: y)) + } + + func switchLens(_ lens: CameraLensType) { focusedSend(RemoteCmd.SwitchLens(lensType: lens)) } + + func setFocusedPeer(_ peer: MCPeerID) { + guard links[peer] != nil else { return } + focusedPeer = peer + publishLanes() + } + + /// Logically remove a camera from the rig. The QUIC session has no + /// per-peer teardown, so this stops the lane (no more acks/handshakes) and + /// drops it from the UI; the peer times out on its side. A true per-peer + /// disconnect needs a transport API and is out of scope here. + func removeCamera(_ peer: MCPeerID) { + links[peer] = nil + order.removeAll { $0 == peer } + if focusedPeer == peer { focusedPeer = order.first } + publishLanes() + } + + private func focusedSend(_ msg: Message) { + guard let peer = focusedPeer else { return } + sendTo(peer, msg) + } + + /// A lane's stall watchdog fired — re-request a frame to unstick just that + /// camera's pump (the others are unaffected). + func nudgeFrame(for peer: MCPeerID) { + guard links[peer] != nil else { return } + sendTo(peer, RemoteCmd.RequestFrame(sender: nil)) + } + + /// A lane's decoder desynced — force a keyframe, but only from a camera + /// that has proven it speaks VP9 (else an old peer reads the unknown + /// action as TakePicture). Mirrors the 1:1 `requestKeyframeIfVP9` gate. + func requestKeyframe(for peer: MCPeerID) { + guard links[peer]?.sawVP9 == true else { return } + sendTo(peer, RemoteCmd.RequestKeyframe(sender: nil), mode: .reliable) + } + + // MARK: - Clock sync + + private func startClockSyncLoop() { + clockSyncTask.value?.cancel() + let interval = clockSyncInterval + clockSyncTask.value = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + guard let self, !Task.isCancelled else { return } + await self.pingClocks() + } + } + } + + private func pingClocks() { + for peer in order where links[peer]?.supportsMulticam == true { + sendTo(peer, RemoteCmd.ClockSyncPing(t0Millis: SyncClock.nowMillis())) + } + } + + // MARK: - Sending + + @discardableResult + private func sendTo(_ peer: MCPeerID, _ msg: Message, + mode: MCSessionSendDataMode = .reliable) -> Bool { + transportShared.value?.send(msg, to: [peer], mode: mode) ?? false + } + + // MARK: - Snapshots + + private func laneSnapshot() -> [MulticamLaneInfo] { + order.compactMap { peer in + guard let link = links[peer] else { return nil } + return MulticamLaneInfo( + peerID: peer, + displayName: link.displayName, + status: link.status, + isFocused: peer == focusedPeer, + clockOffsetMillis: link.latestOffset?.offsetMillis) + } + } + + private func publishLanes() { + let snapshot = laneSnapshot() + let display = display + OperationQueue.main.addOperation { + display?.applyLanes(snapshot) + } + } + + private func isPeerCompatible(_ became: RemoteCmd.RoleAnnouncement) -> Bool { + guard let local = PeerAppCompatibility.localVersion else { return true } + return PeerAppCompatibility.decide(local: local, + remoteShortVersion: became.shortVersion) == .compatible + } +} + +/// Wraps a camera-addressed inbound message with its source peer, so the +/// nonisolated delegate can enqueue routing work onto the FIFO inbox without +/// losing the `from` that Seam A preserved. +final class RoutedMessage: Message, @unchecked Sendable { + let message: Message + let peer: MCPeerID + init(message: Message, peer: MCPeerID) { + self.message = message + self.peer = peer + super.init(sender: nil) + } +} + +/// A clock-sync pong with its arrival time already stamped at receipt, so the +/// RTT is measured before inbox queuing but the record is still ordered. +final class ClockPongMeasured: Message, @unchecked Sendable { + let pong: RemoteCmd.ClockSyncPong + let t3: UInt64 + let peer: MCPeerID + init(pong: RemoteCmd.ClockSyncPong, t3: UInt64, peer: MCPeerID) { + self.pong = pong + self.t3 = t3 + self.peer = peer + super.init(sender: nil) + } +} + +// MARK: - MultipeerServiceDelegate + +extension MulticamController: MultipeerServiceDelegate { + + public nonisolated func didReceiveMessage(_ message: Message, from peer: MCPeerID) { + // Stamp the pong's arrival time (`t3`) here, at receipt, so the RTT is + // measured before any queuing — then route it through the inbox like + // everything else, so ordering and `waitForIdle` hold. Everything else + // carries its source through `RoutedMessage`. + if let pong = message as? RemoteCmd.ClockSyncPong { + tell(ClockPongMeasured(pong: pong, t3: SyncClock.nowMillis(), peer: peer)) + return + } + tell(RoutedMessage(message: message, peer: peer)) + } + + private func storePong(_ pong: RemoteCmd.ClockSyncPong, t3: UInt64, from peer: MCPeerID) { + guard let link = links[peer] else { return } + link.clockEstimator.recordExchange( + t0Millis: pong.echoT0Millis, + cameraClockMillis: pong.cameraClockMillis, + t3Millis: t3) + publishLanes() + } + + public nonisolated func didReceiveFrameRequest(_ request: RemoteCmd.RequestFrame) { + // The director never sends frames, so a frame-credit ack is a no-op. + } + + public nonisolated func didReceiveFrame(_ frame: RemoteCmd.SendFrame, from peer: MCPeerID) { + tell(RemoteCmd.OnFrame(data: frame.data, + sender: nil, + peerId: peer, + fps: frame.fps, + camPosition: frame.camPosition, + camOrientation: frame.camOrientation, + codec: frame.codec, + sequenceNumber: frame.sequenceNumber)) + } + + public nonisolated func peerDidConnect(_ peer: MCPeerID) { + tell(OnConnectToDevice(peer: peer, sender: nil)) + } + + public nonisolated func peerDidDisconnect(_ peer: MCPeerID) { + tell(DisconnectPeer(peer: peer, sender: nil)) + } + + public nonisolated func didDetectIncompatibility() {} + + public nonisolated func browserDidFindPeer(_ peer: MCPeerID) { + tell(UICmd.BrowserFoundPeer(peer: peer)) + } + + public nonisolated func browserDidLosePeer(_ peer: MCPeerID) { + tell(UICmd.BrowserLostPeer(peer: peer)) + } + + public nonisolated func browserDidFail(_ error: Error) {} + public nonisolated func advertiserDidFail(_ error: Error) {} + public nonisolated func didStartReceivingResource(name: String, progress: Progress) {} + public nonisolated func didFinishReceivingResource(name: String, at localURL: URL?, error: Error?) {} +} diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift new file mode 100644 index 0000000..ca3b8af --- /dev/null +++ b/RemoteCam/MulticamView.swift @@ -0,0 +1,139 @@ +// +// MulticamView.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import SwiftUI + +/// The director screen in focus mode: the selected camera fills the viewfinder +/// (reusing the 1:1 monitor's `LiveFrameView` so it looks and behaves the same) +/// with a floating strip of the other cameras' live thumbnails. Grid mode is a +/// later PR; this is the default surface. +struct MulticamView: View { + @ObservedObject var viewModel: MulticamViewModel + + /// Tap a thumbnail to make that camera the focused one. + let onFocusLane: (CameraLane) -> Void + + var body: some View { + GeometryReader { geo in + let dock = MonitorChromeLayout.dock( + viewSize: geo.size, + interfaceOrientation: viewModel.interfaceOrientation, + input: chromeInput) + + ZStack { + Color.black.ignoresSafeArea() + + focusedViewfinder + + stripOverlay(dock: dock) + } + } + } + + private var chromeInput: MonitorChromeInput { + #if targetEnvironment(macCatalyst) + return .pointer + #else + return .touch + #endif + } + + @ViewBuilder + private var focusedViewfinder: some View { + if let focused = viewModel.focusedLane { + LiveFrameView(frames: focused.frames, aspectRatio: .sixteenNine) + .ignoresSafeArea() + } else { + // No camera focused yet (all reconnecting, or none linked). + Rectangle() + .fill(Color.gray.opacity(0.25)) + .overlay( + Image(systemName: "video.slash") + .font(.system(size: 44)) + .foregroundColor(.white.opacity(0.5))) + .ignoresSafeArea() + } + } + + /// The thumbnail rail, docked opposite the (future) action cluster on the + /// same axis the 1:1 chrome uses, so the two screens feel of a piece. + @ViewBuilder + private func stripOverlay(dock: MonitorChromeDock) -> some View { + let others = viewModel.otherLanes + if !others.isEmpty { + switch dock { + case .bottom: + VStack { + Spacer() + HStack(spacing: 8) { + ForEach(others) { lane in + CameraTileView(lane: lane, isThumbnail: true) + .frame(width: 96, height: 128) + .onTapGesture { onFocusLane(lane) } + } + } + .padding(.bottom, 96) + } + case .leading, .trailing: + HStack { + if dock == .trailing { Spacer() } + VStack(spacing: 8) { + ForEach(others) { lane in + CameraTileView(lane: lane, isThumbnail: true) + .frame(width: 128, height: 96) + .onTapGesture { onFocusLane(lane) } + } + } + .padding(dock == .leading ? .leading : .trailing, 12) + if dock == .leading { Spacer() } + } + } + } + } +} + +/// One camera's tile: its isolated live frame, a name chip, a focus ring, and +/// a reconnecting scrim. `Equatable` on the value inputs so a frame delivered +/// to another lane can't invalidate this tile's chrome — only its own +/// `LiveFrameView` (observing its own `FrameDisplayModel`) re-renders. +struct CameraTileView: View { + @ObservedObject var lane: CameraLane + var isThumbnail: Bool = false + + var body: some View { + ZStack { + LiveFrameView(frames: lane.frames, aspectRatio: .sixteenNine) + .clipShape(RoundedRectangle(cornerRadius: isThumbnail ? 10 : 0)) + .saturation(lane.status == .linked ? 1 : 0) + + if lane.status == .reconnecting { + RoundedRectangle(cornerRadius: isThumbnail ? 10 : 0) + .fill(Color.black.opacity(0.45)) + .overlay( + Text(NSLocalizedString("RECONNECTING", comment: "peer link dropped")) + .font(.caption2.weight(.semibold)) + .foregroundColor(.white)) + } + + VStack { + Spacer() + Text(lane.displayName) + .font(.caption2) + .lineLimit(1) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.black.opacity(0.5)) + .clipShape(Capsule()) + .padding(4) + } + } + .overlay( + RoundedRectangle(cornerRadius: isThumbnail ? 10 : 0) + .stroke(lane.isFocused ? AppTheme.accent : .clear, lineWidth: 3)) + } +} diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift new file mode 100644 index 0000000..0dd11c1 --- /dev/null +++ b/RemoteCam/MulticamViewController.swift @@ -0,0 +1,118 @@ +// +// MulticamViewController.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import MPCCompat +import SwiftUI +import UIKit + +/// Hosts the multicam director screen. Owns the `MulticamController` actor and +/// the `MulticamViewModel`, and bridges the two: controller snapshots become +/// lane updates, and per-lane preview frames are routed to exactly one lane's +/// decoder (the rendering-isolation contract). +/// +/// The 1:1 `MonitorViewController` is untouched; this is a parallel screen +/// reached only from a multicam session. +public final class MulticamViewController: UIViewController { + + private let controller: MulticamController + private let viewModel = MulticamViewModel() + private var hosting: UIHostingController? + + /// `controller` must already be `install`-ed with its transport + peers by + /// the caller (the scanner handoff), so lanes light up immediately. + init(controller: MulticamController) { + self.controller = controller + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .allButUpsideDown } + public override var shouldAutorotate: Bool { true } + + public override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + let multicamView = MulticamView( + viewModel: viewModel, + onFocusLane: { [weak self] lane in + guard let self else { return } + Task { await self.controller.setFocusedPeer(lane.peerID) } + }) + hosting = embedSwiftUIView(multicamView) + + Task { await controller.setDisplay(self) } + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + syncInterfaceOrientation() + } + + public override func viewWillTransition(to size: CGSize, + with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate(alongsideTransition: { _ in self.syncInterfaceOrientation() }) + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.setNavigationBarHidden(false, animated: animated) + } + + private func syncInterfaceOrientation() { + let orientation = view.window?.windowScene?.interfaceOrientation ?? .portrait + if viewModel.interfaceOrientation != orientation { + viewModel.interfaceOrientation = orientation + } + } + + deinit { + for lane in viewModel.lanes { lane.receiver.invalidate() } + controller.stop() + } + + /// Wire a freshly created lane's decoder: its frames drive only its own + /// `FrameDisplayModel`, and its stall/keyframe recovery targets only its + /// own peer on the controller. + private func wire(_ lane: CameraLane) { + let peer = lane.peerID + lane.receiver.onImage = { [weak lane] image in + OperationQueue.main.addOperation { lane?.frames.cameraImage = image } + } + lane.receiver.onStall = { [weak self] in + Task { await self?.controller.nudgeFrame(for: peer) } + } + lane.receiver.onKeyframeNeeded = { [weak self] in + Task { await self?.controller.requestKeyframe(for: peer) } + } + lane.receiver.start() + } +} + +// MARK: - MulticamDisplay + +extension MulticamViewController: MulticamDisplay { + + func applyLanes(_ lanes: [MulticamLaneInfo]) { + let created = viewModel.apply(lanes) + for lane in created { wire(lane) } + } + + func receiveFrame(_ frame: RemoteCmd.OnFrame) { + // Route to exactly the source lane's decoder; a frame for camera B + // never touches camera A's tile. + viewModel.lane(for: frame.peerId)?.receiver.receive(frame) + } + + func exitMulticam() { + navigationController?.popViewController(animated: true) + } +} diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift new file mode 100644 index 0000000..799056d --- /dev/null +++ b/RemoteCam/MulticamViewModel.swift @@ -0,0 +1,83 @@ +// +// MulticamViewModel.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import Combine +import Foundation +import MPCCompat +import SwiftUI + +/// One camera's UI state in the director screen. Its `frames` model is +/// isolated exactly like the 1:1 monitor's `FrameDisplayModel`: only this +/// lane's tile observes it, so a 20fps stream from camera B never re-renders +/// camera A's tile or the surrounding chrome. +final class CameraLane: ObservableObject, Identifiable { + let peerID: MCPeerID + var id: MCPeerID { peerID } + let displayName: String + + /// Live preview frames for this lane only (not `@Published` on the parent + /// view model — see `FrameDisplayModel`). + let frames = FrameDisplayModel() + + @Published var status: CameraLink.Status + @Published var isFocused: Bool + + /// This lane's own decoder + stall watchdog. The view controller wires its + /// `onImage` to set `frames.cameraImage`, and its stall/keyframe callbacks + /// back to the controller for this peer. + let receiver = FrameStreamReceiver() + + init(info: MulticamLaneInfo) { + self.peerID = info.peerID + self.displayName = info.displayName + self.status = info.status + self.isFocused = info.isFocused + } +} + +/// Top-level state for the multicam director screen. Holds the ordered lanes +/// and which one is focused; per-lane frame churn lives in each `CameraLane`. +final class MulticamViewModel: ObservableObject { + @Published private(set) var lanes: [CameraLane] = [] + @Published var interfaceOrientation: UIInterfaceOrientation = .portrait + + var focusedLane: CameraLane? { lanes.first { $0.isFocused } } + var otherLanes: [CameraLane] { lanes.filter { !$0.isFocused } } + + /// Reconcile against a controller snapshot: add new lanes, drop gone ones, + /// preserve existing `CameraLane` instances (and their receivers/frames) + /// so streams are never interrupted by a status change elsewhere. + /// + /// Returns the lanes that were newly created, so the view controller can + /// wire their receivers. + @discardableResult + func apply(_ infos: [MulticamLaneInfo]) -> [CameraLane] { + var existing = Dictionary(uniqueKeysWithValues: lanes.map { ($0.peerID, $0) }) + var created: [CameraLane] = [] + + let next: [CameraLane] = infos.map { info in + if let lane = existing.removeValue(forKey: info.peerID) { + if lane.status != info.status { lane.status = info.status } + if lane.isFocused != info.isFocused { lane.isFocused = info.isFocused } + return lane + } + let lane = CameraLane(info: info) + created.append(lane) + return lane + } + + // Tear down receivers for lanes that went away. + for gone in existing.values { gone.receiver.invalidate() } + + lanes = next + return created + } + + func lane(for peer: MCPeerID) -> CameraLane? { + lanes.first { $0.peerID == peer } + } +} diff --git a/RemoteCam/ScannerLobby.swift b/RemoteCam/ScannerLobby.swift index 4a74c0b..dd9ec1e 100644 --- a/RemoteCam/ScannerLobby.swift +++ b/RemoteCam/ScannerLobby.swift @@ -26,6 +26,11 @@ protocol ScannerLobby: AnyObject, Sendable { /// Navigate to the role picker after a peer connects. func goToRole() + /// Multicam collecting: the set of cameras connected so far grew. Lets the + /// scanner show a "Start (N)" affordance. Default no-op — only the + /// production scanner implements it, and only when `ENABLE_MULTICAM`. + func didCollectMulticamCameras(_ peers: [MCPeerID]) + /// Pop navigation back to the scanner screen (called when scanning restarts). func returnToLobby() @@ -33,6 +38,10 @@ protocol ScannerLobby: AnyObject, Sendable { func presentScanningError() } +extension ScannerLobby { + func didCollectMulticamCameras(_ peers: [MCPeerID]) {} +} + /// Binds a `ScannerLobby` to `RemoteCamSession` — the protocol-typed /// counterpart of Theater's `SetViewCtrl` (whose generic parameter requires /// a concrete class). diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 1894b3b..d93a1b4 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -222,6 +222,19 @@ public actor SessionCoordinator { /// camera peer speaks VP9, which gates sending `RemoteCmd.RequestKeyframe`. private var monitorReceivedVP9Frame = false + /// Multicam director "collecting" mode. Off by default and only ever set + /// by the scanner when `ENABLE_MULTICAM` and the monitor role, so every + /// non-multicam path is byte-identical. While set, a peer connecting in + /// `.scanning` is accumulated (the machine stays scanning, keeps browsing) + /// instead of transitioning to `.connected` and auto-advancing to the 1:1 + /// monitor; the scanner reads `multicamCollectedPeers` on "Start". + private var multicamCollecting = false + private var multicamCollectedPeers: [MCPeerID] = [] + + /// Test support. + func multicamCollectingForTesting() -> Bool { multicamCollecting } + func multicamCollectedPeersForTesting() -> [MCPeerID] { multicamCollectedPeers } + /// Test support. func monitorReceivedVP9FrameForTesting() -> Bool { monitorReceivedVP9Frame } @@ -295,6 +308,45 @@ public actor SessionCoordinator { var connectedPeers: [MCPeerID] { multipeerService?.connectedPeers ?? [] } + /// How many cameras the collecting scanner has connected. The scanner + /// reads this on "Start" to choose the single-camera vs director path. + func multicamConnectedCount() -> Int { multicamCollecting ? connectedPeers.count : 0 } + + /// Hand the live transport (and the ≥2 cameras it is connected to) to a + /// `MulticamController`. Detaches this coordinator from the transport — + /// nils its references without stopping the session — so the multicam + /// controller becomes the sole delegate and this coordinator's `stop()` + /// (on scanner teardown) cannot kill a session the director is using. + /// Returns nil unless collecting with two or more cameras (the single + /// camera case stays on the classic monitor — see below). + func detachTransportForMulticam() -> (transport: any MultipeerServiceProtocol, peers: [MCPeerID])? { + guard multicamCollecting, let transport = multipeerService else { return nil } + let peers = transport.connectedPeers + guard peers.count >= 2 else { return nil } + multipeerService = nil + transportShared.value = nil + multicamCollecting = false + multicamCollectedPeers = [] + return (transport, peers) + } + + /// The single-camera exit from collecting: promote the one connected peer + /// to a normal `.connected` session so the classic `MonitorViewController` + /// path runs exactly as it does without the flag. Returns false (leaving + /// the scanner as-is) unless collecting with exactly one camera. + func promoteSingleCollectedToConnected() async -> Bool { + guard multicamCollecting, connectedPeers.count == 1, + let peer = connectedPeers.first, let liveLobby = lobby?.value else { return false } + multicamCollecting = false + multicamCollectedPeers = [] + link = .linked(peer) + OperationQueue.main.addOperation { + liveLobby.scannerViewModel.connectedToPeer() + } + await transition(to: .connected) + return true + } + private func unableToProcessError(_ msg: Message) async -> NSError { let deviceName = await MainActor.run { UIDevice.current.name } return NSError( @@ -691,6 +743,22 @@ public actor SessionCoordinator { break case let connected as OnConnectToDevice: + if multicamCollecting { + // Accumulate and stay scanning: the director wants several + // cameras, so keep browsing/inviting and let the scanner show a + // growing set. The transport holds every connection; the handoff + // reads them on "Start". No transition to `.connected` (which + // would stop browsing and auto-advance). + if !multicamCollectedPeers.contains(connected.peer) { + multicamCollectedPeers.append(connected.peer) + } + link = .none // free the invite slot so the next camera can dial + let peers = multicamCollectedPeers + OperationQueue.main.addOperation { + liveLobby.didCollectMulticamCameras(peers) + } + break + } link = .linked(connected.peer) OperationQueue.main.addOperation { liveLobby.scannerViewModel.connectedToPeer() @@ -1402,6 +1470,9 @@ public actor SessionCoordinator { case is UICmd.AppForegrounded: await rearmAfterForeground() + case let collect as UICmd.SetMulticamCollecting: + multicamCollecting = collect.on + case is UICmd.PeerTrafficObserved: // Arrives with every inbound message; only a wait cares (see // `inReconnecting`), and it is swallowed here so the rest of the diff --git a/RemoteCam/UICmds.swift b/RemoteCam/UICmds.swift index f934eb8..182093e 100644 --- a/RemoteCam/UICmds.swift +++ b/RemoteCam/UICmds.swift @@ -564,4 +564,17 @@ extension UICmd { super.init(sender: nil) } } + + /// Multicam director "collecting" mode: while set, the scanner accumulates + /// several connected cameras instead of auto-advancing to the 1:1 monitor + /// on the first connect. Sent by the scanner only when `ENABLE_MULTICAM` + /// and the monitor role — off, the coordinator's scanning path is + /// byte-identical to before. + public class SetMulticamCollecting: Message, @unchecked Sendable { + let on: Bool + init(on: Bool) { + self.on = on + super.init(sender: nil) + } + } } diff --git a/RemoteCam/en.lproj/Localizable.strings b/RemoteCam/en.lproj/Localizable.strings index 622cb8c..be15bbe 100644 --- a/RemoteCam/en.lproj/Localizable.strings +++ b/RemoteCam/en.lproj/Localizable.strings @@ -359,3 +359,6 @@ "IncompatibleBothTitle" = "App is out of date"; "IncompatibleBothBody" = "Please update Remote Shutter on both devices."; "IncompatibleUpdateButton" = "Update"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Start (%d)"; diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift new file mode 100644 index 0000000..9a1ab65 --- /dev/null +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -0,0 +1,190 @@ +// +// MulticamControllerTests.swift +// RemoteShutterTests +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import MPCCompat +import XCTest +@testable import RemoteShutter + +/// Captures what the controller pushes to the screen. +private final class FakeMulticamDisplay: MulticamDisplay, @unchecked Sendable { + var lastLanes: [MulticamLaneInfo] = [] + var receivedFrames: [MCPeerID] = [] + var didExit = false + + func applyLanes(_ lanes: [MulticamLaneInfo]) { lastLanes = lanes } + func receiveFrame(_ frame: RemoteCmd.OnFrame) { receivedFrames.append(frame.peerId) } + func exitMulticam() { didExit = true } +} + +final class MulticamControllerTests: XCTestCase { + + private let camA = MCPeerID(displayName: "CameraA") + private let camB = MCPeerID(displayName: "CameraB") + + private func makeController(peers: [MCPeerID]) + async -> (MulticamController, FakeMultipeerService, FakeMulticamDisplay) { + let controller = MulticamController() + let transport = FakeMultipeerService() + transport.sendResult = true + transport.connectedPeers = peers + let display = FakeMulticamDisplay() + await controller.setDisplay(display) + await controller.install(transport: transport, initialPeers: peers, mode: .photo) + await controller.waitForIdle() + return (controller, transport, display) + } + + private func sent(_ transport: FakeMultipeerService, _ type: T.Type) + -> [(msg: Message, peers: [MCPeerID], mode: MCSessionSendDataMode)] { + transport.sentMessages.filter { $0.msg is T } + } + + // MARK: - Handshake + + func testInstallSeedsLaneAndHandshakesEveryCamera() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + + let lanes = await controller.lanesForTesting() + XCTAssertEqual(Set(lanes.map(\.peerID)), [camA, camB]) + + // Each camera is asked for capabilities, exactly addressed to itself. + let requests = sent(transport, RemoteCmd.RequestCameraCapabilities.self) + XCTAssertEqual(Set(requests.flatMap(\.peers)), [camA, camB]) + for req in requests { XCTAssertEqual(req.peers.count, 1) } + + // And discovery keeps running so more cameras can be added later. + XCTAssertGreaterThanOrEqual(transport.discoveryStarts, 1) + } + + func testFirstCameraIsFocused() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + let focused = await controller.focusedPeerForTesting() + XCTAssertEqual(focused, camA) + } + + // MARK: - Capabilities → live + + func testCapabilitiesMarkLaneLinkedAndPingClockWhenMulticam() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + transport.sentMessages.removeAll() + + controller.didReceiveMessage(multicamCaps(), from: camA) + await controller.waitForIdle() + + let statusA = await controller.statusForTesting(camA) + XCTAssertEqual(statusA, .linked) + // A multicam-capable camera gets an immediate clock probe, to itself. + let pings = sent(transport, RemoteCmd.ClockSyncPing.self) + XCTAssertEqual(pings.map(\.peers), [[camA]]) + } + + // MARK: - Frame routing (Seam B) + + func testFrameRoutesToItsLaneAndAcksOnlyItsSource() async { + let (controller, transport, display) = await makeController(peers: [camA, camB]) + transport.sentMessages.removeAll() + + controller.didReceiveFrame(sendFrame(), from: camB) + await controller.waitForIdle() + + XCTAssertEqual(display.receivedFrames, [camB]) + let acks = sent(transport, RemoteCmd.RequestFrame.self) + XCTAssertEqual(acks.map(\.peers), [[camB]], + "the frame ack must address only the camera that sent the frame") + } + + // MARK: - Focused-camera commands + + func testPerCameraCommandTargetsOnlyTheFocusedPeer() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + await controller.setFocusedPeer(camB) + transport.sentMessages.removeAll() + + await controller.setZoom(2.0) + await controller.waitForIdle() + + let zooms = sent(transport, RemoteCmd.SetZoom.self) + XCTAssertEqual(zooms.map(\.peers), [[camB]]) + } + + // MARK: - Disconnect / reconnect + + func testDisconnectDegradesOnlyThatLane() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + + controller.peerDidDisconnect(camA) + await controller.waitForIdle() + + let statusA = await controller.statusForTesting(camA) + let statusB = await controller.statusForTesting(camB) + XCTAssertEqual(statusA, .reconnecting) + XCTAssertEqual(statusB, .linked, "one camera dropping must not disturb the others") + } + + func testBrowserReinvitesOnlyAReconnectingCamera() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + controller.peerDidDisconnect(camA) + await controller.waitForIdle() + transport.invitedPeers.removeAll() + + controller.browserDidFindPeer(camA) + await controller.waitForIdle() + XCTAssertEqual(transport.invitedPeers.map(\.peer), [camA]) + + // A camera that is already linked is not re-invited on a browser hit. + transport.invitedPeers.removeAll() + controller.browserDidFindPeer(camB) + await controller.waitForIdle() + XCTAssertTrue(transport.invitedPeers.isEmpty) + } + + // MARK: - Clock sync + + func testPongUpdatesThatLanesOffset() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + + // Camera A is 500ms ahead; symmetric 20ms RTT is faked via the pong's + // camera clock relative to our own — we only assert an offset landed. + controller.didReceiveMessage( + RemoteCmd.ClockSyncPong(echoT0Millis: SyncClock.nowMillis(), + cameraClockMillis: SyncClock.nowMillis() + 500), + from: camA) + await controller.waitForIdle() + + let offsetA = await controller.offsetForTesting(camA) + let offsetB = await controller.offsetForTesting(camB) + XCTAssertNotNil(offsetA) + XCTAssertNil(offsetB) + } + + // MARK: - Removal + + func testRemoveCameraDropsTheLaneAndRefocuses() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + + await controller.removeCamera(camA) + let lanes = await controller.lanesForTesting() + XCTAssertEqual(lanes.map(\.peerID), [camB]) + let focusedAfter = await controller.focusedPeerForTesting() + XCTAssertEqual(focusedAfter, camB) + } + + // MARK: - Fixtures + + private func multicamCaps() -> RemoteCmd.CameraCapabilitiesResp { + RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + supportsMulticam: true, error: nil) + } + + private func sendFrame() -> RemoteCmd.SendFrame { + RemoteCmd.SendFrame(data: Data([1, 2, 3]), sender: nil, + fps: 30, camPosition: .back, camOrientation: .portrait, + codec: .vp9, sequenceNumber: 1) + } +} diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift new file mode 100644 index 0000000..a97ce3f --- /dev/null +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -0,0 +1,62 @@ +// +// MulticamViewModelTests.swift +// RemoteShutterTests +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import MPCCompat +import XCTest +@testable import RemoteShutter + +final class MulticamViewModelTests: XCTestCase { + + private let camA = MCPeerID(displayName: "CameraA") + private let camB = MCPeerID(displayName: "CameraB") + + private func info(_ peer: MCPeerID, status: CameraLink.Status = .linked, + focused: Bool = false) -> MulticamLaneInfo { + MulticamLaneInfo(peerID: peer, displayName: peer.displayName, + status: status, isFocused: focused, clockOffsetMillis: nil) + } + + func testApplyAddsLanesAndReportsCreated() { + let vm = MulticamViewModel() + let created = vm.apply([info(camA, focused: true), info(camB)]) + XCTAssertEqual(vm.lanes.map(\.peerID), [camA, camB]) + XCTAssertEqual(created.map(\.peerID), [camA, camB]) + } + + func testApplyPreservesExistingLaneInstances() { + let vm = MulticamViewModel() + vm.apply([info(camA, focused: true), info(camB)]) + let laneABefore = vm.lane(for: camA) + + // Second apply changes only status; the CameraLane (and its live + // frames/receiver) must be the same instance, not rebuilt. + let created = vm.apply([info(camA, status: .reconnecting, focused: true), info(camB)]) + XCTAssertTrue(created.isEmpty, "no new lanes should be created") + XCTAssertTrue(vm.lane(for: camA) === laneABefore) + XCTAssertEqual(vm.lane(for: camA)?.status, .reconnecting) + } + + func testApplyDropsGoneLanes() { + let vm = MulticamViewModel() + vm.apply([info(camA, focused: true), info(camB)]) + vm.apply([info(camA, focused: true)]) + XCTAssertEqual(vm.lanes.map(\.peerID), [camA]) + XCTAssertNil(vm.lane(for: camB)) + } + + func testFocusedAndOtherLanesPartition() { + let vm = MulticamViewModel() + vm.apply([info(camA, focused: true), info(camB)]) + XCTAssertEqual(vm.focusedLane?.peerID, camA) + XCTAssertEqual(vm.otherLanes.map(\.peerID), [camB]) + + // Refocusing moves the partition without rebuilding lanes. + vm.apply([info(camA), info(camB, focused: true)]) + XCTAssertEqual(vm.focusedLane?.peerID, camB) + XCTAssertEqual(vm.otherLanes.map(\.peerID), [camA]) + } +} diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index 1fb4164..9de1589 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -203,6 +203,13 @@ CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0120000000000002 /* MonitorChrome.swift */; }; CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */; }; CAFEBABE0132000000000001 /* ClockOffsetEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */; }; + CAFEBABE0140000000000001 /* CameraLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0140000000000002 /* CameraLink.swift */; }; + CAFEBABE0141000000000001 /* MulticamController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0141000000000002 /* MulticamController.swift */; }; + CAFEBABE0142000000000001 /* MulticamViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0142000000000002 /* MulticamViewModel.swift */; }; + CAFEBABE0143000000000001 /* MulticamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0143000000000002 /* MulticamView.swift */; }; + CAFEBABE0144000000000001 /* MulticamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0144000000000002 /* MulticamViewController.swift */; }; + CAFEBABE0145000000000002 /* MulticamControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0145000000000001 /* MulticamControllerTests.swift */; }; + CAFEBABE0146000000000002 /* MulticamViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0146000000000001 /* MulticamViewModelTests.swift */; }; CAFEBABE0133000000000002 /* ClockOffsetEstimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */; }; CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */; }; FEEDFACE0000000000000001 /* StormoLoopbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDFACE0000000000000002 /* StormoLoopbackTests.swift */; }; @@ -448,6 +455,13 @@ CAFEBABE0120000000000002 /* MonitorChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonitorChrome.swift; sourceTree = ""; }; CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaptureSyncMetadata.swift; sourceTree = ""; }; CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ClockOffsetEstimator.swift; sourceTree = ""; }; + CAFEBABE0140000000000002 /* CameraLink.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraLink.swift; sourceTree = ""; }; + CAFEBABE0141000000000002 /* MulticamController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MulticamController.swift; sourceTree = ""; }; + CAFEBABE0142000000000002 /* MulticamViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MulticamViewModel.swift; sourceTree = ""; }; + CAFEBABE0143000000000002 /* MulticamView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MulticamView.swift; sourceTree = ""; }; + CAFEBABE0144000000000002 /* MulticamViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MulticamViewController.swift; sourceTree = ""; }; + CAFEBABE0145000000000001 /* MulticamControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MulticamControllerTests.swift; sourceTree = ""; }; + CAFEBABE0146000000000001 /* MulticamViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MulticamViewModelTests.swift; sourceTree = ""; }; CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClockOffsetEstimatorTests.swift; sourceTree = ""; }; CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureSyncMetadataTests.swift; sourceTree = ""; }; FCB0F6BA2086BF9BB4242D66 /* Pods_RemoteShutterWatch.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RemoteShutterWatch.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -601,6 +615,8 @@ CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */, CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */, + CAFEBABE0145000000000001 /* MulticamControllerTests.swift */, + CAFEBABE0146000000000001 /* MulticamViewModelTests.swift */, CAFEBABE0002000000000001 /* WatchCaptureCountdownTests.swift */, CAFEBABE0003000000000001 /* WatchSerializationTests.swift */, CAFEBABE0006000000000001 /* WatchPreviewStreamerTests.swift */, @@ -695,6 +711,11 @@ CAFEBABE0120000000000002 /* MonitorChrome.swift */, CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */, CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */, + CAFEBABE0140000000000002 /* CameraLink.swift */, + CAFEBABE0141000000000002 /* MulticamController.swift */, + CAFEBABE0142000000000002 /* MulticamViewModel.swift */, + CAFEBABE0143000000000002 /* MulticamView.swift */, + CAFEBABE0144000000000002 /* MulticamViewController.swift */, 060B1E141BE7079800077BCC /* Helpers */, 06E965202535199400E5A8B3 /* MediaProcessors.swift */, 06E9652625351E3F00E5A8B3 /* SwiftConstants.swift */, @@ -1189,6 +1210,11 @@ CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */, CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */, CAFEBABE0132000000000001 /* ClockOffsetEstimator.swift in Sources */, + CAFEBABE0140000000000001 /* CameraLink.swift in Sources */, + CAFEBABE0141000000000001 /* MulticamController.swift in Sources */, + CAFEBABE0142000000000001 /* MulticamViewModel.swift in Sources */, + CAFEBABE0143000000000001 /* MulticamView.swift in Sources */, + CAFEBABE0144000000000001 /* MulticamViewController.swift in Sources */, 06BB79B82E374D410094E085 /* FeatureFlags.swift in Sources */, 0692844F1BE5C0E600AF4678 /* MultipeerMessages.swift in Sources */, 069284531BE5C0E600AF4678 /* RemoteCamStateNames.swift in Sources */, @@ -1263,6 +1289,8 @@ CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */, CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */, CAFEBABE0133000000000002 /* ClockOffsetEstimatorTests.swift in Sources */, + CAFEBABE0145000000000002 /* MulticamControllerTests.swift in Sources */, + CAFEBABE0146000000000002 /* MulticamViewModelTests.swift in Sources */, CAFEBABE0002000000000002 /* WatchCaptureCountdownTests.swift in Sources */, CAFEBABE0003000000000002 /* WatchSerializationTests.swift in Sources */, CAFEBABE0006000000000002 /* WatchPreviewStreamerTests.swift in Sources */,