diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index aaa2bdf49c8..49fca08ca86 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -43,13 +43,18 @@ const HEADER_HEIGHT: f32 = 36.; /// `h-[42px]` in deviceRowStyles.ts. const DEVICE_ROW_HEIGHT: f32 = 42.; +fn connected_camera<'a>( + id: &recording::DeviceOrModelID, + cameras: &'a [CameraOption], +) -> Option<&'a CameraOption> { + cameras.iter().find(|camera| match id { + recording::DeviceOrModelID::DeviceID(id) => camera.device_id == *id, + recording::DeviceOrModelID::ModelID(model) => camera.model_id.as_ref() == Some(model), + }) +} + fn remembered_camera(id: &recording::DeviceOrModelID, cameras: &[CameraOption]) -> CameraOption { - cameras - .iter() - .find(|camera| match id { - recording::DeviceOrModelID::DeviceID(id) => camera.device_id == *id, - recording::DeviceOrModelID::ModelID(model) => camera.model_id.as_ref() == Some(model), - }) + connected_camera(id, cameras) .cloned() .unwrap_or_else(|| CameraOption { device_id: match id { @@ -88,6 +93,38 @@ fn remembered_microphone(name: &str, microphones: &[MicrophoneOption]) -> Microp }) } +/// A remembered input stays selected while it is unplugged, so a start has to +/// go ahead without it rather than fail on its open error. Camera-only is the +/// one target that cannot record without its camera. The input lists only +/// refresh while a picker is open, so the start installs a fresh scan first. +fn skip_disconnected_inputs( + config: &mut recording::StartConfig, + devices: &mut DeviceSnapshot, + fresh_inputs: impl IntoIterator, +) { + for inputs in fresh_inputs { + inputs.install(devices); + } + let camera_missing = config + .camera + .as_ref() + .is_some_and(|id| connected_camera(id, &devices.cameras).is_none()); + if camera_missing && !matches!(config.target, ScreenCaptureTarget::CameraOnly) { + tracing::warn!("Selected camera is not connected, recording without it"); + config.camera = None; + config.camera_feed = None; + } + let microphone_missing = config + .microphone + .as_ref() + .is_some_and(|name| !devices.microphones.iter().any(|mic| &mic.name == name)); + if microphone_missing { + tracing::warn!("Selected microphone is not connected, recording without it"); + config.microphone = None; + config.mic_feed = None; + } +} + fn take_pending_recording_inputs( pending: &mut crate::store::RecordingInputSettings, enumerating: bool, @@ -2461,7 +2498,7 @@ impl MainWindow { pub(crate) fn start_recording_config( &mut self, - config: recording::StartConfig, + mut config: recording::StartConfig, cx: &mut Context, ) { if self.enumerating @@ -2478,25 +2515,49 @@ impl MainWindow { let Ok(permit) = self.prepare_deep_link_start(cx) else { return; }; - let microphone_available = config.microphone.as_ref().is_some_and(|name| { - self.devices - .microphones - .iter() - .any(|device| &device.name == name) - }); - if !microphone_available && crate::store::GeneralSettings::load().confirm_without_microphone - { - self.microphone_warning = Some(MicrophoneWarning { - config, - permit, - dont_show_again: false, - error: None, - }); - cx.defer(app_windows::show_main_window); - cx.notify(); - return; - } - self.check_storage_before_start(config, false, permit, cx); + let previous_cameras = config + .camera + .is_some() + .then(|| self.devices.cameras.clone()); + let refresh_microphones = config.microphone.is_some(); + cx.spawn(async move |this, cx| { + let fresh_inputs = cx + .background_executor() + .spawn(async move { + [ + previous_cameras.map(|previous| devices::InputSnapshot::cameras(&previous)), + refresh_microphones.then(devices::InputSnapshot::microphones), + ] + }) + .await; + this.update(cx, |this, cx| { + if !this.recording_start_is_current(&permit, cx) { + this.finish_recording_start(&permit); + return; + } + skip_disconnected_inputs( + &mut config, + &mut this.devices, + fresh_inputs.into_iter().flatten(), + ); + cx.notify(); + if config.microphone.is_none() + && crate::store::GeneralSettings::load().confirm_without_microphone + { + this.microphone_warning = Some(MicrophoneWarning { + config, + permit, + dont_show_again: false, + error: None, + }); + cx.defer(app_windows::show_main_window); + return; + } + this.check_storage_before_start(config, false, permit, cx); + }) + .ok(); + }) + .detach(); } pub(crate) fn prepare_deep_link_start( @@ -2590,8 +2651,6 @@ impl MainWindow { cx.notify(); return; } - warning.config.microphone = None; - warning.config.mic_feed = None; self.check_storage_before_start(warning.config, false, warning.permit, cx); cx.notify(); } @@ -7387,6 +7446,123 @@ mod remembered_device_tests { ); } + fn start_config( + target: ScreenCaptureTarget, + camera: recording::DeviceOrModelID, + microphone: &str, + ) -> recording::StartConfig { + recording::StartConfig { + mode: recording::RecordingMode::Studio, + target, + microphone: Some(microphone.to_string()), + camera: Some(camera), + device_settings: crate::store::RecordingDeviceSettings::default(), + input_readiness: feeds::InputReadiness::default(), + system_audio: false, + excluded_windows: Vec::new(), + camera_feed: None, + mic_feed: None, + mic_meter: None, + #[cfg(target_os = "linux")] + linux_instant_camera: None, + start_gate: None, + } + } + + fn display_target() -> ScreenCaptureTarget { + ScreenCaptureTarget::Display { + id: "1".parse().unwrap(), + } + } + + fn connected_devices() -> DeviceSnapshot { + DeviceSnapshot { + cameras: vec![camera("connected-camera", Some("046d:08e5"))], + microphones: vec![MicrophoneOption { + name: "Connected microphone".into(), + sample_rate: None, + channels: None, + }], + ..Default::default() + } + } + + fn scan(snapshot: DeviceSnapshot) -> [devices::InputSnapshot; 2] { + [ + devices::InputSnapshot::Cameras(snapshot.cameras), + devices::InputSnapshot::Microphones(snapshot.microphones), + ] + } + + #[test] + fn a_start_checks_a_fresh_scan_rather_than_the_launch_lists() { + let camera = recording::DeviceOrModelID::DeviceID("connected-camera".into()); + + let mut unplugged_since_launch = connected_devices(); + let mut config = start_config(display_target(), camera.clone(), "Connected microphone"); + skip_disconnected_inputs( + &mut config, + &mut unplugged_since_launch, + scan(DeviceSnapshot::default()), + ); + assert!(config.camera.is_none()); + assert!(config.microphone.is_none()); + assert!(unplugged_since_launch.cameras.is_empty()); + assert!(unplugged_since_launch.microphones.is_empty()); + + let mut plugged_in_since_launch = DeviceSnapshot::default(); + let mut config = start_config(display_target(), camera.clone(), "Connected microphone"); + skip_disconnected_inputs( + &mut config, + &mut plugged_in_since_launch, + scan(connected_devices()), + ); + assert_eq!(config.camera, Some(camera)); + assert_eq!(config.microphone.as_deref(), Some("Connected microphone")); + assert_eq!(plugged_in_since_launch.cameras, connected_devices().cameras); + } + + #[test] + fn a_start_skips_remembered_inputs_that_are_unplugged() { + let unplugged_model = cap_camera::ModelID::try_from("1234:5678".to_string()).unwrap(); + for camera in [ + recording::DeviceOrModelID::DeviceID("unplugged-camera".into()), + recording::DeviceOrModelID::ModelID(unplugged_model), + ] { + let mut config = start_config(display_target(), camera, "Unplugged microphone"); + skip_disconnected_inputs(&mut config, &mut connected_devices(), []); + assert!(config.camera.is_none()); + assert!(config.microphone.is_none()); + } + } + + #[test] + fn a_start_keeps_connected_inputs() { + let connected_model = cap_camera::ModelID::try_from("046d:08e5".to_string()).unwrap(); + for camera in [ + recording::DeviceOrModelID::DeviceID("connected-camera".into()), + recording::DeviceOrModelID::ModelID(connected_model), + ] { + let mut config = start_config(display_target(), camera.clone(), "Connected microphone"); + skip_disconnected_inputs(&mut config, &mut connected_devices(), []); + assert_eq!(config.camera, Some(camera)); + assert_eq!(config.microphone.as_deref(), Some("Connected microphone")); + } + } + + #[test] + fn camera_only_still_requires_its_camera() { + let unplugged = recording::DeviceOrModelID::DeviceID("unplugged-camera".into()); + let mut config = start_config( + ScreenCaptureTarget::CameraOnly, + unplugged.clone(), + "Unplugged microphone", + ); + skip_disconnected_inputs(&mut config, &mut connected_devices(), []); + assert_eq!(config.camera, Some(unplugged)); + assert!(config.microphone.is_none()); + } + #[test] fn closing_before_enumeration_defers_saved_inputs_until_reopen_once() { let saved = crate::store::RecordingInputSettings {