Calls Mac: remember the camera you pick, and pass over a closed lid
Paul's 0.3.3 report, "This keeps forgetting my input": a MacBook Air in
clamshell with an ATEM Mini ("Blackmagic Design") on USB. The remembered
camera was the MacBook's own, which with the lid closed is listed and
connected but suspended, and sends nothing. Every call waited out the
watchdog, fell back to the Blackmagic and said "No signal from MacBook
Air Camera; using Blackmagic Design".
Root cause: the panel could not record the pick that would have fixed
it. The Camera picker was bound to state that mirrors the camera in use,
and a pick only counted through onChange with `now != call.currentCamera`.
On the fallback the picker already showed the Blackmagic, so choosing it
changed nothing and the guard would have dropped it anyway. Fallbacks and
first-run defaults never wrote the preference (now held by a test); the
preference was simply never replaced.
- The picker's own setter is the pick, so choosing the camera the call
is already on counts. The director remembers it and, when that camera
is live, does not reopen it.
- "Use <camera> from now on" appears while a working camera other than
the remembered one is in use: one tap, never an automatic switch.
- A suspended camera (AVCaptureDevice.isSuspended; confirmed true for a
clamshell MacBook's built-in camera) is passed over while there is
another, with "MacBook Air Camera is off while the lid is closed;
using Blackmagic Design". The preference is kept: opening the lid goes
back to it. The lid closing mid-call falls back at once. isSuspended is
observed by KVO so neither waits for a device list change.
- The preference is matched by uniqueID, then modelID, then name: a USB
camera's uniqueID follows its port, so a moved ATEM still matches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+328
−43
native/apple/Sources/CallsKit/Call.swift+6 −0native/apple/Sources/CallsKit/CameraRecovery.swift+106 −27native/apple/Sources/CallsKit/Capture.swift+40 −4native/apple/Sources/CallsKit/DevicePanel.swift+26 −10native/apple/Sources/CallsKit/Media.swift+2 −1native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift+148 −1
CI passed — run details
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 3f32290..e0a2ce3 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -80,6 +80,9 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| /// call. |
| @Published public private(set) var cameraStatus: CameraStatus? |
| @Published public private(set) var cameraNote = "" |
| + /// The working camera in use when it is not the remembered one, which |
| + /// the panel offers to remember. |
| + @Published public private(set) var cameraToKeep: CameraDevice? |
| /// A real camera, so the panel shows its row even with nothing in it. |
| @Published public private(set) var offersCameras = false |
| /// Audio routing belongs to this call. |
| @@ -188,6 +191,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| currentCamera = nil |
| cameraStatus = nil |
| cameraNote = "" |
| + cameraToKeep = nil |
| knocks.reset() |
| cameraSwitches += 1 |
| cameraWork?.cancel() |
| @@ -265,6 +269,8 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| if cameraStatus != status { cameraStatus = status } |
| let note = media.cameraNote |
| if cameraNote != note { cameraNote = note } |
| + let keep = media.cameraToKeep |
| + if cameraToKeep != keep { cameraToKeep = keep } |
| } |
| /// What your own tile says in place of your picture, or nil to show it. |
diff --git a/native/apple/Sources/CallsKit/CameraRecovery.swift b/native/apple/Sources/CallsKit/CameraRecovery.swift| index 10b57c0..4c92111 100644 |
| --- a/native/apple/Sources/CallsKit/CameraRecovery.swift |
| +++ b/native/apple/Sources/CallsKit/CameraRecovery.swift |
| @@ -60,28 +60,53 @@ public enum CameraStatus: Equatable, Sendable { |
| /// The person's camera, remembered between calls and launches. |
| /// |
| -/// Kept by `uniqueID`, which survives unplugging and replugging, with the |
| -/// name beside it so the panel can say which camera it is waiting for |
| -/// while that camera is not connected. |
| +/// Kept by `uniqueID`, with the model and name beside it. The uniqueID of |
| +/// a USB camera follows the port it is plugged into, so the same ATEM |
| +/// Mini on another port is a new id; `find` falls back to the model and |
| +/// then the name, so the remembered camera is still found. The name also |
| +/// lets the panel say which camera it is waiting for while it is gone. |
| +/// |
| +/// Only a person's own pick writes this (`CameraDirector.choose`, and the |
| +/// panel before a call). A fallback, a skip and a first-run default never |
| +/// do: which camera a call is on is not the same as which one you chose. |
| public struct CameraPreference: Equatable, Sendable { |
| public var id: String |
| public var name: String |
| - public init(id: String, name: String) { |
| + /// The camera's model, for finding it again under a new id. Empty for a |
| + /// choice saved before 0.3.4, which is then found by name. |
| + public var model: String |
| + public init(id: String, name: String, model: String = "") { |
| self.id = id |
| self.name = name |
| + self.model = model |
| + } |
| + public init(_ device: CameraDevice) { |
| + self.init(id: device.id, name: device.name, model: device.model) |
| } |
| static let idKey = "preferredCameraID" |
| static let nameKey = "preferredCameraName" |
| + static let modelKey = "preferredCameraModel" |
| public static func load(_ defaults: UserDefaults = .standard) -> CameraPreference? { |
| guard let id = defaults.string(forKey: idKey), !id.isEmpty else { return nil } |
| - return CameraPreference(id: id, name: defaults.string(forKey: nameKey) ?? "") |
| + return CameraPreference( |
| + id: id, name: defaults.string(forKey: nameKey) ?? "", model: defaults.string(forKey: modelKey) ?? "") |
| + } |
| + |
| + /// The connected camera this preference means: by id, then by model, |
| + /// then by name. |
| + public func find(in devices: [CameraDevice]) -> CameraDevice? { |
| + if let device = devices.first(where: { $0.id == id }) { return device } |
| + if !model.isEmpty, let device = devices.first(where: { $0.model == model }) { return device } |
| + if !name.isEmpty, let device = devices.first(where: { $0.name == name }) { return device } |
| + return nil |
| } |
| public func save(_ defaults: UserDefaults = .standard) { |
| defaults.set(id, forKey: Self.idKey) |
| defaults.set(name, forKey: Self.nameKey) |
| + defaults.set(model, forKey: Self.modelKey) |
| } |
| } |
| @@ -188,7 +213,18 @@ enum CameraCommand: Equatable { |
| /// |
| /// - At join the remembered camera is used if it is connected, otherwise |
| /// the built-in one, otherwise whatever there is. |
| -/// - Choosing a camera remembers it. |
| +/// - A suspended camera (a Mac's built-in one with the lid closed, which |
| +/// is listed but never sends a frame) is passed over while there is |
| +/// another, and the panel says why ("MacBook Air Camera is off while |
| +/// the lid is closed; using Blackmagic Design"). It is still the |
| +/// remembered camera: opening the lid, mid-call or before the next one, |
| +/// goes back to it. Closing the lid mid-call falls back at once rather |
| +/// than waiting out the watchdog. |
| +/// - Choosing a camera remembers it, including the one the call is |
| +/// already on by fallback. Nothing else writes the preference. While |
| +/// the call is on a working camera other than the remembered one, the |
| +/// panel offers to remember it ("Use Blackmagic Design from now on"): |
| +/// one tap, never an automatic switch. |
| /// - The camera in use disconnecting falls back to another at once, and |
| /// says so ("ATEM Mini disconnected; using FaceTime HD Camera"). |
| /// - The remembered camera reconnecting, or appearing later, is switched |
| @@ -227,6 +263,23 @@ enum CameraCommand: Equatable { |
| var preference: CameraPreference? { CameraPreference.load(defaults) } |
| + /// The camera in use, when it is working and is not the remembered |
| + /// one: what the panel offers to remember. |
| + var cameraToKeep: CameraDevice? { |
| + guard status.live, let id = active, let device = devices.first(where: { $0.id == id }) else { return nil } |
| + guard let preference, preference.find(in: devices)?.id != id else { return nil } |
| + return device |
| + } |
| + |
| + /// "MacBook Air Camera is off while the lid is closed": what a |
| + /// suspended camera is called when it is passed over. |
| + nonisolated static func off(_ device: CameraDevice) -> String { |
| + #if os(macOS) |
| + if device.builtIn { return "\(device.name) is off while the lid is closed" } |
| + #endif |
| + return "\(device.name) is off" |
| + } |
| + |
| private func name(_ id: String?) -> String { |
| guard let id else { return "The camera" } |
| if let device = devices.first(where: { $0.id == id }) { return device.name } |
| @@ -235,14 +288,17 @@ enum CameraCommand: Equatable { |
| } |
| /// The camera a call should be on, given what is connected: the |
| - /// remembered one, then the built-in one, then the first. Pure, and |
| - /// what `start` and every fallback ask. |
| + /// remembered one, then the built-in one, then the first — each only if |
| + /// it is not suspended, and a suspended one only when there is nothing |
| + /// else. Pure, and what `start` and every fallback ask. |
| nonisolated static func choose( |
| - _ devices: [CameraDevice], preferred: String?, excluding: Set<String> = [] |
| + _ devices: [CameraDevice], preferred: CameraPreference?, excluding: Set<String> = [] |
| ) -> CameraDevice? { |
| let usable = devices.filter { !excluding.contains($0.id) } |
| - if let preferred, let device = usable.first(where: { $0.id == preferred }) { return device } |
| - return usable.first(where: \.builtIn) ?? usable.first |
| + let awake = usable.filter { !$0.suspended } |
| + if let device = preferred?.find(in: awake) { return device } |
| + if let device = awake.first(where: \.builtIn) ?? awake.first { return device } |
| + return preferred?.find(in: usable) ?? usable.first(where: \.builtIn) ?? usable.first |
| } |
| /// A call starts: pick the camera and open it. |
| @@ -258,18 +314,20 @@ enum CameraCommand: Equatable { |
| /// Chooses from scratch, excluding what has failed this round. |
| private func pick(missingNote: Bool) -> CameraCommand? { |
| let preferred = preference |
| - guard let device = Self.choose(devices, preferred: preferred?.id, excluding: silent.union(refused)) |
| + guard let device = Self.choose(devices, preferred: preferred, excluding: silent.union(refused)) |
| else { |
| active = nil |
| status = devices.isEmpty ? .noCamera : status |
| if devices.isEmpty { note = "" } |
| return .stop |
| } |
| - if missingNote, let preferred, preferred.id != device.id, |
| - !devices.contains(where: { $0.id == preferred.id }) |
| - { |
| - let missing = preferred.name.isEmpty ? "Your camera" : preferred.name |
| - note = "\(missing) isn’t connected; using \(device.name)" |
| + if missingNote, let preferred { |
| + if let found = preferred.find(in: devices) { |
| + if found.id != device.id, found.suspended { note = "\(Self.off(found)); using \(device.name)" } |
| + } else { |
| + let missing = preferred.name.isEmpty ? "Your camera" : preferred.name |
| + note = "\(missing) isn’t connected; using \(device.name)" |
| + } |
| } |
| return open(device.id) |
| } |
| @@ -296,7 +354,7 @@ enum CameraCommand: Equatable { |
| guard id == active else { return nil } |
| let failedName = name(id) |
| refused.insert(id) |
| - if let other = Self.choose(devices, preferred: preference?.id, excluding: silent.union(refused)) { |
| + if let other = Self.choose(devices, preferred: preference, excluding: silent.union(refused)) { |
| note = "\(failedName) would not start; using \(other.name)" |
| return open(other.id) |
| } |
| @@ -334,10 +392,14 @@ enum CameraCommand: Equatable { |
| private func fallBack(from id: String) -> CameraCommand? { |
| let silentName = name(id) |
| if let other = Self.choose( |
| - devices, preferred: preference?.id, excluding: silent.union(refused).union([id])) |
| + devices, preferred: preference, excluding: silent.union(refused).union([id])) |
| { |
| silent.insert(id) |
| - note = "No signal from \(silentName); using \(other.name)" |
| + if let device = devices.first(where: { $0.id == id }), device.suspended { |
| + note = "\(Self.off(device)); using \(other.name)" |
| + } else { |
| + note = "No signal from \(silentName); using \(other.name)" |
| + } |
| return open(other.id) |
| } |
| // Nothing to fall back to. Stay on it, say so, and keep counting: an |
| @@ -350,6 +412,7 @@ enum CameraCommand: Equatable { |
| /// The connected cameras changed. |
| func devicesChanged(_ now: [CameraDevice]) -> CameraCommand? { |
| let before = Set(devices.map(\.id)) |
| + let wasSuspended = Set(devices.filter(\.suspended).map(\.id)) |
| let previous = devices |
| devices = now |
| let present = Set(now.map(\.id)) |
| @@ -360,7 +423,7 @@ enum CameraCommand: Equatable { |
| let preferred = preference |
| if let id = active, !present.contains(id) { |
| let gone = previous.first(where: { $0.id == id })?.name ?? name(id) |
| - if let other = Self.choose(now, preferred: preferred?.id, excluding: silent.union(refused)) { |
| + if let other = Self.choose(now, preferred: preferred, excluding: silent.union(refused)) { |
| note = "\(gone) disconnected; using \(other.name)" |
| return open(other.id) |
| } |
| @@ -370,12 +433,24 @@ enum CameraCommand: Equatable { |
| watchdog.reset() |
| return .stop |
| } |
| - // The remembered camera is back (or has just appeared): go back to it. |
| - if let preferred, active != preferred.id, present.contains(preferred.id), |
| - !before.contains(preferred.id) || active == nil |
| + // The camera in use was suspended (the lid closed on it): fall back |
| + // now, while there is somewhere awake to go, rather than wait out the |
| + // watchdog's three tries on a camera that cannot send. |
| + if let id = active, let device = now.first(where: { $0.id == id }), device.suspended, |
| + let other = Self.choose(now, preferred: preferred, excluding: silent.union(refused).union([id])), |
| + !other.suspended |
| + { |
| + note = "\(Self.off(device)); using \(other.name)" |
| + return open(other.id) |
| + } |
| + // The remembered camera is back, has just appeared, or has woken (the |
| + // lid opened): go back to it. |
| + if let target = preferred?.find(in: now), active != target.id, !target.suspended, |
| + !before.contains(target.id) || wasSuspended.contains(target.id) || active == nil |
| { |
| + silent.remove(target.id) |
| note = "" |
| - return open(preferred.id) |
| + return open(target.id) |
| } |
| // Nothing running and something to run on. |
| if active == nil, !now.isEmpty { |
| @@ -387,13 +462,17 @@ enum CameraCommand: Equatable { |
| return nil |
| } |
| - /// The person chose a camera: it is remembered and used. |
| + /// The person chose a camera: it is remembered and used. Choosing the |
| + /// camera the call is already on — there by fallback, say — is a choice |
| + /// like any other; when that camera is working it is only remembered, |
| + /// not reopened, so the picture does not blink. |
| func choose(_ id: String) -> CameraCommand? { |
| guard let device = devices.first(where: { $0.id == id }) else { return nil } |
| - CameraPreference(id: device.id, name: device.name).save(defaults) |
| + CameraPreference(device).save(defaults) |
| silent.remove(id) |
| refused.remove(id) |
| note = "" |
| + if active == id, status.live { return nil } |
| watchdog.reset() |
| return open(id) |
| } |
diff --git a/native/apple/Sources/CallsKit/Capture.swift b/native/apple/Sources/CallsKit/Capture.swift| index a39a622..96926f0 100644 |
| --- a/native/apple/Sources/CallsKit/Capture.swift |
| +++ b/native/apple/Sources/CallsKit/Capture.swift |
| @@ -20,10 +20,22 @@ public struct CameraDevice: Identifiable, Hashable, Sendable { |
| /// The Mac's own camera (the iPhone's front one): where a call goes |
| /// when the remembered camera is not connected. |
| public let builtIn: Bool |
| - public init(id: String, name: String, builtIn: Bool = false) { |
| + /// `AVCaptureDevice.modelID`: the same for the same kind of camera |
| + /// wherever it is plugged in. A USB camera's `uniqueID` is built from |
| + /// the port it is on, so an ATEM Mini moved to another port (or through |
| + /// a different hub) comes back under a new id; the model, and failing |
| + /// that the name, is how a remembered choice still finds it. |
| + public let model: String |
| + /// `AVCaptureDevice.isSuspended`: listed and connected, but the system |
| + /// will not let it capture. On a Mac this is the built-in camera with |
| + /// the lid closed, where it opens and never sends a frame. |
| + public let suspended: Bool |
| + public init(id: String, name: String, builtIn: Bool = false, model: String = "", suspended: Bool = false) { |
| self.id = id |
| self.name = name |
| self.builtIn = builtIn |
| + self.model = model |
| + self.suspended = suspended |
| } |
| } |
| @@ -51,6 +63,10 @@ public struct CameraDevice: Identifiable, Hashable, Sendable { |
| /// A fallback worth saying ("ATEM Mini disconnected; using FaceTime HD |
| /// Camera"), and usually nothing. |
| var cameraNote: String { get } |
| + /// The camera in use when it is working and is not the remembered one: |
| + /// what the panel offers to remember ("Use Blackmagic Design from now |
| + /// on"). Nil otherwise. |
| + var cameraToKeep: CameraDevice? { get } |
| /// Called on the main actor whenever any of the above changes on its |
| /// own — a camera connecting, a fallback, the first frame. |
| func observe(_ changed: @escaping @MainActor () -> Void) |
| @@ -65,6 +81,7 @@ extension VideoCapture { |
| public var offersCameras: Bool { false } |
| public var cameraStatus: CameraStatus? { nil } |
| public var cameraNote: String { "" } |
| + public var cameraToKeep: CameraDevice? { nil } |
| public func observe(_ changed: @escaping @MainActor () -> Void) {} |
| public func retryCamera() async {} |
| } |
| @@ -158,6 +175,7 @@ final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendabl |
| public var offersCameras: Bool { true } |
| public var cameraStatus: CameraStatus? { running ? director.status : nil } |
| public var cameraNote: String { running ? director.note : "" } |
| + public var cameraToKeep: CameraDevice? { running ? director.cameraToKeep : nil } |
| public func observe(_ changed: @escaping @MainActor () -> Void) { self.changed = changed } |
| public func start(source: RTCVideoSource) async throws { |
| @@ -246,9 +264,9 @@ final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendabl |
| } |
| } |
| - private var published: (CameraStatus, String, String?)? |
| + private var published: (CameraStatus, String, String?, CameraDevice?)? |
| private func notify() { |
| - let now = (director.status, director.note, currentCamera) |
| + let now = (director.status, director.note, currentCamera, director.cameraToKeep) |
| if let published, published == now { return } |
| published = now |
| changed?() |
| @@ -315,6 +333,9 @@ final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendabl |
| public private(set) var cameras: [CameraDevice] = [] |
| private var listeners: [UUID: @MainActor () -> Void] = [:] |
| private var tokens: [NSObjectProtocol] = [] |
| + /// Each listed camera's `isSuspended`, watched: the lid closing or |
| + /// opening changes it without any camera connecting or leaving. |
| + private var suspension: [String: NSKeyValueObservation] = [:] |
| #if os(macOS) |
| private let session = AVCaptureDevice.DiscoverySession( |
| deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], |
| @@ -335,6 +356,18 @@ final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendabl |
| } |
| #endif |
| cameras = Self.list(devices) |
| + watchSuspension() |
| + } |
| + |
| + private func watchSuspension() { |
| + let current = devices |
| + let ids = Set(current.map(\.uniqueID)) |
| + for id in suspension.keys where !ids.contains(id) { suspension.removeValue(forKey: id) } |
| + for device in current where suspension[device.uniqueID] == nil { |
| + suspension[device.uniqueID] = device.observe(\.isSuspended, options: [.new]) { _, _ in |
| + DispatchQueue.main.async { MainActor.assumeIsolated { CameraWatch.shared.refresh() } } |
| + } |
| + } |
| } |
| var devices: [AVCaptureDevice] { |
| @@ -354,13 +387,16 @@ final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendabl |
| #else |
| let builtIn = $0.position == .front |
| #endif |
| - return CameraDevice(id: $0.uniqueID, name: $0.localizedName, builtIn: builtIn) |
| + return CameraDevice( |
| + id: $0.uniqueID, name: $0.localizedName, builtIn: builtIn, model: $0.modelID, |
| + suspended: $0.isSuspended) |
| } |
| } |
| public func refresh() { |
| let now = Self.list(devices) |
| guard now != cameras else { return } |
| + watchSuspension() |
| cameras = now |
| for listener in listeners.values { listener() } |
| } |
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift| index cde891b..0594867 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanel.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanel.swift |
| @@ -76,15 +76,20 @@ import SwiftUI |
| try? await Task.sleep(for: .seconds(1)) |
| } |
| } |
| - .onChange(of: camera) { was, now in |
| - guard let now, now != was, now != call.currentCamera else { return } |
| - cameraRequest += 1 |
| - let request = cameraRequest |
| - Task { |
| - await call.useCamera(now) |
| - // Only the latest pick puts the picker back where the camera is. |
| - if request == cameraRequest { camera = call.currentCamera } |
| - } |
| + } |
| + |
| + /// A camera the person picked, remembered and switched to. Driven by the |
| + /// picker's own setter, which only a person's choice calls — so choosing |
| + /// the camera the call is already on (there by fallback, say) counts, as |
| + /// it did not in 0.3.3, and the picker following the capture never does. |
| + private func pickCamera(_ id: String) { |
| + camera = id |
| + cameraRequest += 1 |
| + let request = cameraRequest |
| + Task { |
| + await call.useCamera(id) |
| + // Only the latest pick puts the picker back where the camera is. |
| + if request == cameraRequest { camera = call.currentCamera } |
| } |
| } |
| @@ -101,7 +106,10 @@ import SwiftUI |
| row("Camera", "No camera found") |
| } else { |
| picker( |
| - "Camera", selection: $camera, |
| + "Camera", |
| + selection: Binding( |
| + get: { camera }, |
| + set: { id in if let id { pickCamera(id) } }), |
| options: call.cameras.map { ($0.id, $0.name) }, identifier: "cameraPicker") |
| } |
| if case .picker(let line, _) = state, let line { |
| @@ -115,6 +123,14 @@ import SwiftUI |
| .fixedSize(horizontal: false, vertical: true) |
| .accessibilityIdentifier("cameraNote") |
| } |
| + if let keep = call.cameraToKeep { |
| + Button("Use \(keep.name) from now on", systemImage: "checkmark.circle") { |
| + pickCamera(keep.id) |
| + } |
| + .buttonStyle(.bordered) |
| + .accessibilityIdentifier("cameraKeep") |
| + .accessibilityHint("Remembers this camera for your next calls.") |
| + } |
| if state.offersRetry { |
| Button("Try again", systemImage: "arrow.clockwise") { |
| Task { await call.retryCamera() } |
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift| index a33cd0c..47c45eb 100644 |
| --- a/native/apple/Sources/CallsKit/Media.swift |
| +++ b/native/apple/Sources/CallsKit/Media.swift |
| @@ -83,11 +83,12 @@ import LiveKitWebRTC |
| private(set) var offersCameras = false |
| var cameraStatus: CameraStatus? { capture?.cameraStatus } |
| var cameraNote: String { capture?.cameraNote ?? "" } |
| + var cameraToKeep: CameraDevice? { capture?.cameraToKeep } |
| func use(camera id: String) async throws { |
| if let capture { |
| try await capture.use(camera: id) |
| } else if offersCameras, let device = CameraWatch.shared.cameras.first(where: { $0.id == id }) { |
| - CameraPreference(id: device.id, name: device.name).save() |
| + CameraPreference(device).save() |
| } |
| } |
| func retryCamera() async { |
diff --git a/native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift b/native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift| index db5a2f6..8e83b3a 100644 |
| --- a/native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift |
| @@ -46,7 +46,7 @@ import XCTest |
| XCTAssertEqual(d.start(devices: [atem, faceTime]), .open("facetime", format: 0)) |
| XCTAssertEqual(d.note, "") |
| XCTAssertEqual(CameraDirector.choose([atem, phone], preferred: nil)?.id, "atem") |
| - XCTAssertNil(CameraDirector.choose([], preferred: "atem")) |
| + XCTAssertNil(CameraDirector.choose([], preferred: CameraPreference(id: "atem", name: "ATEM Mini"))) |
| } |
| func testNoCameraAtAllIsSaidAndStopped() { |
| @@ -67,6 +67,137 @@ import XCTest |
| XCTAssertNil(d.choose("missing"), "a camera that is not connected cannot be chosen") |
| } |
| + // MARK: Paul's 0.3.3 report: "This keeps forgetting my input" |
| + // |
| + // A MacBook Air in clamshell, an ATEM Mini ("Blackmagic Design") on USB. |
| + // The remembered camera was the MacBook's own, which is listed with the |
| + // lid closed but sends nothing; every call fell back to the Blackmagic |
| + // and said "No signal from MacBook Air Camera; using Blackmagic Design", |
| + // and choosing the Blackmagic in the panel did not stick. |
| + |
| + private let macBook = CameraDevice( |
| + id: "builtin", name: "MacBook Air Camera", builtIn: true, model: "FaceTime HD Camera") |
| + private let lidClosed = CameraDevice( |
| + id: "builtin", name: "MacBook Air Camera", builtIn: true, model: "FaceTime HD Camera", suspended: true) |
| + private let blackmagic = CameraDevice( |
| + id: "0x1100000_1edb_be55", name: "Blackmagic Design", model: "UVC Camera VendorID_7899 ProductID_48725") |
| + |
| + /// The fallback, as 0.3.3 reached it: preferred camera silent, the |
| + /// Blackmagic live. |
| + private func onBlackmagicByFallback(_ store: UserDefaults) -> CameraDirector { |
| + CameraPreference(macBook).save(store) |
| + let d = director(store, formats: 1) |
| + _ = d.start(devices: [macBook, blackmagic]) |
| + d.opened("builtin", at: 0, frames: 0) |
| + _ = d.tick(frames: 0, at: 3) |
| + d.opened("builtin", at: 3, frames: 0) |
| + XCTAssertEqual(d.tick(frames: 0, at: 6), .open(blackmagic.id, format: 0)) |
| + d.opened(blackmagic.id, at: 6, frames: 0) |
| + _ = d.tick(frames: 1, at: 6.5) |
| + XCTAssertEqual(d.status, .live("Blackmagic Design")) |
| + return d |
| + } |
| + |
| + func testChoosingTheCameraYouAreOnByFallbackRemembersItWithoutRestartingIt() { |
| + let store = defaults() |
| + let d = onBlackmagicByFallback(store) |
| + XCTAssertNil(d.choose(blackmagic.id), "already live: nothing to reopen, no glitch") |
| + XCTAssertEqual(CameraPreference.load(store), CameraPreference(blackmagic)) |
| + XCTAssertEqual(d.note, "") |
| + XCTAssertEqual(d.status, .live("Blackmagic Design")) |
| + XCTAssertNil(d.cameraToKeep, "nothing left to offer") |
| + // And the next call starts on it. |
| + let next = director(store) |
| + XCTAssertEqual(next.start(devices: [macBook, blackmagic]), .open(blackmagic.id, format: 0)) |
| + } |
| + |
| + func testFallbacksAndDefaultsNeverWriteThePreference() { |
| + let store = defaults() |
| + _ = onBlackmagicByFallback(store) |
| + XCTAssertEqual(CameraPreference.load(store), CameraPreference(macBook), "a fallback is not a choice") |
| + let fresh = defaults() |
| + let d = director(fresh) |
| + _ = d.start(devices: [blackmagic, macBook]) |
| + _ = d.devicesChanged([blackmagic]) |
| + _ = d.openFailed(blackmagic.id) |
| + XCTAssertNil(CameraPreference.load(fresh), "a first-run default is not a choice either") |
| + } |
| + |
| + func testTheCameraInUseIsOfferedWhenItIsNotTheRememberedOne() { |
| + let store = defaults() |
| + let d = onBlackmagicByFallback(store) |
| + XCTAssertEqual(d.cameraToKeep, blackmagic) |
| + XCTAssertEqual(d.choose(blackmagic.id), nil) |
| + XCTAssertEqual(CameraPreference.load(store)?.id, blackmagic.id) |
| + // Nothing remembered and on the default: nothing to offer. |
| + let plain = director(defaults()) |
| + _ = plain.start(devices: [macBook, blackmagic]) |
| + plain.opened("builtin", at: 0, frames: 0) |
| + _ = plain.tick(frames: 1, at: 0.5) |
| + XCTAssertNil(plain.cameraToKeep) |
| + } |
| + |
| + func testAReenumeratedCameraStillMatches() { |
| + // Moved to another USB port: a new uniqueID, the same model. |
| + let store = defaults() |
| + CameraPreference(blackmagic).save(store) |
| + let moved = CameraDevice(id: "0x1400000_1edb_be55", name: "Blackmagic Design", model: blackmagic.model) |
| + XCTAssertEqual(director(store).start(devices: [macBook, moved]), .open(moved.id, format: 0)) |
| + // A choice saved by 0.3.3 has no model: the name finds it. |
| + let old = defaults() |
| + CameraPreference(id: blackmagic.id, name: "Blackmagic Design").save(old) |
| + let d = director(old) |
| + XCTAssertEqual(d.start(devices: [macBook, moved]), .open(moved.id, format: 0)) |
| + XCTAssertEqual(d.note, "", "found, so not \"isn’t connected\"") |
| + XCTAssertNil(d.cameraToKeep, "it is the remembered camera") |
| + // The id still wins over the model when both are there. |
| + let twin = CameraDevice(id: "twin", name: "Blackmagic Design", model: blackmagic.model) |
| + XCTAssertEqual(director(store).start(devices: [twin, blackmagic]), .open(blackmagic.id, format: 0)) |
| + } |
| + |
| + func testLidClosedSkipsTheBuiltInCameraAtJoinAndSaysWhy() { |
| + let store = defaults() |
| + CameraPreference(macBook).save(store) |
| + let d = director(store) |
| + XCTAssertEqual(d.start(devices: [lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) |
| + XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| + XCTAssertEqual(CameraPreference.load(store), CameraPreference(macBook), "skipped, not forgotten") |
| + d.opened(blackmagic.id, at: 0, frames: 0) |
| + _ = d.tick(frames: 1, at: 0.5) |
| + XCTAssertEqual(d.cameraToKeep, blackmagic) |
| + // With nothing remembered, a closed lid is not the first choice either. |
| + XCTAssertEqual(director(defaults()).start(devices: [lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) |
| + // And with nothing else, it is tried anyway. |
| + XCTAssertEqual(director(defaults()).start(devices: [lidClosed]), .open("builtin", format: 0)) |
| + } |
| + |
| + func testLidClosingMidCallFallsBackAtOnceAndOpeningItReturns() { |
| + let store = defaults() |
| + CameraPreference(macBook).save(store) |
| + let d = director(store) |
| + _ = d.start(devices: [macBook, blackmagic]) |
| + d.opened("builtin", at: 0, frames: 0) |
| + _ = d.tick(frames: 1, at: 0.5) |
| + XCTAssertEqual(d.devicesChanged([lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) |
| + XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| + XCTAssertEqual(d.devicesChanged([macBook, blackmagic]), .open("builtin", format: 0)) |
| + XCTAssertEqual(d.note, "") |
| + } |
| + |
| + func testNoSignalFromALidClosedCameraSaysSo() { |
| + // The lid closed after the list was read: the watchdog finds it. |
| + let store = defaults() |
| + CameraPreference(macBook).save(store) |
| + let d = director(store, formats: 1) |
| + _ = d.start(devices: [macBook, blackmagic]) |
| + d.opened("builtin", at: 0, frames: 0) |
| + _ = d.tick(frames: 0, at: 3) |
| + d.opened("builtin", at: 3, frames: 0) |
| + _ = d.devicesChanged([lidClosed, blackmagic]) |
| + XCTAssertEqual(d.active, blackmagic.id) |
| + XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| + } |
| + |
| // MARK: Coming and going |
| func testActiveCameraDisconnectingFallsBackAtOnceAndSaysSo() { |
| @@ -319,6 +450,22 @@ import XCTest |
| camera.stop() |
| } |
| + func testManagedCameraOnTheLidClosedFallbackRemembersThePick() async throws { |
| + let store = defaults() |
| + CameraPreference(macBook).save(store) |
| + let backend = FakeBackend([lidClosed, blackmagic]) |
| + let camera = ManagedCamera(backend: backend, defaults: store) |
| + RTCInitializeSSL() |
| + try await camera.start(source: RTCPeerConnectionFactory().videoSource()) |
| + XCTAssertEqual(backend.opened, [blackmagic.id], "the closed lid's camera is never opened") |
| + XCTAssertEqual(camera.cameraNote, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| + // Picking the camera it is already on is remembered. |
| + try await camera.use(camera: blackmagic.id) |
| + XCTAssertEqual(CameraPreference.load(store), CameraPreference(blackmagic)) |
| + XCTAssertEqual(camera.cameraNote, "") |
| + camera.stop() |
| + } |
| + |
| private func waitFor(_ condition: @escaping @MainActor () -> Bool) async throws { |
| for _ in 0..<200 { |
| if condition() { return } |