oficina / meet Public

Merge branch 'fix/camera-preference' into main

Merge fix/camera-preference: remember the camera you pick, and pass over a closed lid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1a9a2fa63cd9b31989152ca38919f82d5069411a parent 0ab6ca9parent 6ac2415
6 files changed, +328 −43
  • native/apple/Sources/CallsKit/Call.swift +6 −0
  • native/apple/Sources/CallsKit/CameraRecovery.swift +106 −27
  • native/apple/Sources/CallsKit/Capture.swift +40 −4
  • native/apple/Sources/CallsKit/DevicePanel.swift +26 −10
  • native/apple/Sources/CallsKit/Media.swift +2 −1
  • native/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 }