From 80e8710e028f347cb65649d952f703836b9681cb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:03:25 +0000 Subject: [PATCH 01/18] refactor(rendering): centralize wgpu instance creation and software adapter detection --- crates/rendering/src/lib.rs | 143 +++++++++++++++++++++++++++--------- 1 file changed, 108 insertions(+), 35 deletions(-) diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index ce1ceaf476..36c2dde8cb 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -50,6 +50,57 @@ use text::{PreparedText, prepare_texts}; use zoom::*; pub use zoom_focus_interpolation::ZoomFocusInterpolator; +pub fn is_software_wgpu_adapter(info: &wgpu::AdapterInfo) -> bool { + matches!(info.device_type, wgpu::DeviceType::Cpu) + || info + .name + .to_lowercase() + .contains("microsoft basic render driver") +} + +pub async fn create_wgpu_instance() -> wgpu::Instance { + #[cfg(not(target_os = "windows"))] + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); + + #[cfg(target_os = "windows")] + let instance = { + let dx12_instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::DX12, + ..Default::default() + }); + let has_dx12 = dx12_instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + }) + .await + .is_ok(); + if has_dx12 { + dx12_instance + } else { + wgpu::Instance::new(&wgpu::InstanceDescriptor::default()) + } + }; + + instance +} + +pub async fn probe_software_adapter() -> Option<(bool, String)> { + let instance = create_wgpu_instance().await; + + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + }) + .await + .ok()?; + let info = adapter.get_info(); + Some((is_software_wgpu_adapter(&info), info.name)) +} + const STANDARD_CURSOR_HEIGHT: f32 = 75.0; fn rounding_type_value(style: CornerStyle) -> f32 { @@ -990,6 +1041,7 @@ pub struct RenderVideoConstants { pub recording_meta: RecordingMeta, pub background_textures: std::sync::Arc>>, pub is_software_adapter: bool, + adapter_name: String, } pub struct SharedWgpuDevice { @@ -1019,6 +1071,8 @@ impl RenderVideoConstants { let background_textures = Arc::new(tokio::sync::RwLock::new(HashMap::new())); + let adapter_name = shared.adapter.get_info().name; + Ok(Self { _instance: shared.instance, _adapter: shared.adapter, @@ -1029,9 +1083,35 @@ impl RenderVideoConstants { meta, recording_meta, is_software_adapter: shared.is_software_adapter, + adapter_name, }) } + pub fn adapter_name(&self) -> &str { + &self.adapter_name + } + + pub fn from_shared_device( + shared: SharedWgpuDevice, + options: RenderOptions, + meta: StudioRecordingMeta, + recording_meta: RecordingMeta, + ) -> Self { + let adapter_name = shared.adapter.get_info().name; + Self { + _instance: shared.instance, + _adapter: shared.adapter, + device: shared.device, + queue: shared.queue, + options, + background_textures: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + meta, + recording_meta, + is_software_adapter: shared.is_software_adapter, + adapter_name, + } + } + pub async fn new( segments: &[SegmentRecordings], recording_meta: RecordingMeta, @@ -1047,31 +1127,7 @@ impl RenderVideoConstants { .map(|c| XY::new(c.width, c.height)), }; - #[cfg(not(target_os = "windows"))] - let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); - - #[cfg(target_os = "windows")] - let instance = { - let dx12_instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { - backends: wgpu::Backends::DX12, - ..Default::default() - }); - let has_dx12 = dx12_instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, - force_fallback_adapter: false, - compatible_surface: None, - }) - .await - .is_ok(); - if has_dx12 { - tracing::info!("Using DX12 backend for optimal D3D11 interop"); - dx12_instance - } else { - tracing::info!("DX12 not available, falling back to all backends"); - wgpu::Instance::new(&wgpu::InstanceDescriptor::default()) - } - }; + let instance = create_wgpu_instance().await; let hardware_adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { @@ -1082,13 +1138,27 @@ impl RenderVideoConstants { .await .ok(); - let (adapter, is_software_adapter) = if let Some(adapter) = hardware_adapter { - tracing::info!( - adapter_name = adapter.get_info().name, - adapter_backend = ?adapter.get_info().backend, - "Using hardware GPU adapter" - ); - (adapter, false) + let (adapter, is_software_adapter, adapter_name) = if let Some(adapter) = hardware_adapter { + let adapter_info = adapter.get_info(); + let is_software = is_software_wgpu_adapter(&adapter_info); + + if is_software { + tracing::warn!( + adapter_name = adapter_info.name, + adapter_backend = ?adapter_info.backend, + adapter_device_type = ?adapter_info.device_type, + "Hardware adapter behaves like a software renderer" + ); + } else { + tracing::info!( + adapter_name = adapter_info.name, + adapter_backend = ?adapter_info.backend, + adapter_device_type = ?adapter_info.device_type, + "Using hardware GPU adapter" + ); + } + + (adapter, is_software, adapter_info.name) } else { tracing::warn!("No hardware GPU adapter found, attempting software fallback"); let software_adapter = instance @@ -1100,12 +1170,14 @@ impl RenderVideoConstants { .await .map_err(|_| RenderingError::NoAdapter)?; + let adapter_info = software_adapter.get_info(); tracing::info!( - adapter_name = software_adapter.get_info().name, - adapter_backend = ?software_adapter.get_info().backend, + adapter_name = adapter_info.name, + adapter_backend = ?adapter_info.backend, + adapter_device_type = ?adapter_info.device_type, "Using software adapter (CPU rendering - performance may be reduced)" ); - (software_adapter, true) + (software_adapter, true, adapter_info.name) }; let mut required_features = wgpu::Features::empty(); @@ -1133,6 +1205,7 @@ impl RenderVideoConstants { meta, recording_meta, is_software_adapter, + adapter_name, }) } } From de1985ca3d91a3ea7190862ee6b7f433c591f60b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:03:28 +0000 Subject: [PATCH 02/18] refactor(desktop): use centralized wgpu utilities in gpu_context --- apps/desktop/src-tauri/src/gpu_context.rs | 59 ++++++++++------------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src-tauri/src/gpu_context.rs b/apps/desktop/src-tauri/src/gpu_context.rs index 4fb5fe6da6..99a8de2643 100644 --- a/apps/desktop/src-tauri/src/gpu_context.rs +++ b/apps/desktop/src-tauri/src/gpu_context.rs @@ -49,31 +49,7 @@ pub struct SharedGpuContext { static GPU: OnceCell> = OnceCell::const_new(); async fn init_gpu_inner() -> Option { - #[cfg(not(target_os = "windows"))] - let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); - - #[cfg(target_os = "windows")] - let instance = { - let dx12_instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { - backends: wgpu::Backends::DX12, - ..Default::default() - }); - let has_dx12 = dx12_instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, - force_fallback_adapter: false, - compatible_surface: None, - }) - .await - .is_ok(); - if has_dx12 { - tracing::info!("Using DX12 backend for shared GPU context"); - dx12_instance - } else { - tracing::info!("DX12 not available for shared context, falling back to all backends"); - wgpu::Instance::new(&wgpu::InstanceDescriptor::default()) - } - }; + let instance = cap_rendering::create_wgpu_instance().await; let hardware_adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { @@ -85,12 +61,26 @@ async fn init_gpu_inner() -> Option { .ok(); let (adapter, is_software_adapter) = if let Some(adapter) = hardware_adapter { - tracing::info!( - adapter_name = adapter.get_info().name, - adapter_backend = ?adapter.get_info().backend, - "Using hardware GPU adapter for shared context" - ); - (adapter, false) + let adapter_info = adapter.get_info(); + let is_software_adapter = cap_rendering::is_software_wgpu_adapter(&adapter_info); + + if is_software_adapter { + tracing::warn!( + adapter_name = adapter_info.name, + adapter_backend = ?adapter_info.backend, + adapter_device_type = ?adapter_info.device_type, + "Selected shared-context adapter behaves like a software renderer" + ); + } else { + tracing::info!( + adapter_name = adapter_info.name, + adapter_backend = ?adapter_info.backend, + adapter_device_type = ?adapter_info.device_type, + "Using hardware GPU adapter for shared context" + ); + } + + (adapter, is_software_adapter) } else { tracing::warn!( "No hardware GPU adapter found, attempting software fallback for shared context" @@ -104,9 +94,12 @@ async fn init_gpu_inner() -> Option { .await .ok()?; + let adapter_info = software_adapter.get_info(); + tracing::info!( - adapter_name = software_adapter.get_info().name, - adapter_backend = ?software_adapter.get_info().backend, + adapter_name = adapter_info.name, + adapter_backend = ?adapter_info.backend, + adapter_device_type = ?adapter_info.device_type, "Using software adapter for shared context (CPU rendering - performance may be reduced)" ); (software_adapter, true) From 6a5cac61f8650888b4c8ef3ccc6a0630ed58f3a1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:03:32 +0000 Subject: [PATCH 03/18] refactor(desktop): use SharedWgpuDevice and from_shared_device in screenshot editor --- .../src-tauri/src/screenshot_editor.rs | 87 +++++++++---------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index 1e539dd44e..339514e272 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -249,62 +249,61 @@ impl ScreenshotEditorInstances { } }; - let (instance, adapter, device, queue, is_software_adapter) = - if let Some(shared) = gpu_context::get_shared_gpu().await { - ( - shared.instance.clone(), - shared.adapter.clone(), - shared.device.clone(), - shared.queue.clone(), - shared.is_software_adapter, - ) - } else { - let instance = - Arc::new(wgpu::Instance::new(&wgpu::InstanceDescriptor::default())); - let adapter = Arc::new( - instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, - force_fallback_adapter: false, - compatible_surface: None, - }) - .await - .map_err(|_| "No GPU adapter found".to_string())?, - ); - - let (device, queue) = adapter - .request_device(&wgpu::DeviceDescriptor { - label: Some("cap-rendering-device"), - required_features: wgpu::Features::empty(), - ..Default::default() - }) - .await - .map_err(|e| e.to_string())?; - (instance, adapter, Arc::new(device), Arc::new(queue), false) - }; + let shared = if let Some(gpu) = gpu_context::get_shared_gpu().await { + cap_rendering::SharedWgpuDevice { + instance: (*gpu.instance).clone(), + adapter: (*gpu.adapter).clone(), + device: (*gpu.device).clone(), + queue: (*gpu.queue).clone(), + is_software_adapter: gpu.is_software_adapter, + } + } else { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + }) + .await + .map_err(|_| "No GPU adapter found".to_string())?; + let adapter_info = adapter.get_info(); + let is_software_adapter = + cap_rendering::is_software_wgpu_adapter(&adapter_info); + + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("cap-rendering-device"), + required_features: wgpu::Features::empty(), + ..Default::default() + }) + .await + .map_err(|e| e.to_string())?; + cap_rendering::SharedWgpuDevice { + instance, + adapter, + device, + queue, + is_software_adapter, + } + }; let options = cap_rendering::RenderOptions { screen_size: cap_project::XY::new(width, height), camera_size: None, }; - // We need to extract the studio meta from the recording meta let studio_meta = match &recording_meta.inner { RecordingMetaInner::Studio(meta) => meta.clone(), _ => return Err("Invalid recording meta for screenshot".to_string()), }; - let constants = RenderVideoConstants { - _instance: (*instance).clone(), - _adapter: (*adapter).clone(), - queue: (*queue).clone(), - device: (*device).clone(), + let constants = RenderVideoConstants::from_shared_device( + shared, options, - meta: *studio_meta, - recording_meta: recording_meta.clone(), - background_textures: Arc::new(tokio::sync::RwLock::new(HashMap::new())), - is_software_adapter, - }; + *studio_meta, + recording_meta.clone(), + ); let (config_tx, mut config_rx) = watch::channel(loaded_config.unwrap_or_default()); From 7ac489b9a79a8b3eba301588a6f8de2d723a8b9f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:03:40 +0000 Subject: [PATCH 04/18] refactor(cap-test): use adapter_name accessor in performance tests --- crates/cap-test/src/suites/performance.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/cap-test/src/suites/performance.rs b/crates/cap-test/src/suites/performance.rs index d44d064076..52dae20c02 100644 --- a/crates/cap-test/src/suites/performance.rs +++ b/crates/cap-test/src/suites/performance.rs @@ -126,10 +126,7 @@ async fn run_open_test(recording_path: &Path) -> TestResult { }; let mut notes = vec![ - format!( - "adapter={}", - context.render_constants._adapter.get_info().name - ), + format!("adapter={}", context.render_constants.adapter_name()), format!( "software_adapter={}", context.render_constants.is_software_adapter @@ -347,7 +344,7 @@ async fn benchmark_playback( let mut metrics = PlaybackMetrics { requested_frames, setup_secs, - adapter_name: context.render_constants._adapter.get_info().name, + adapter_name: context.render_constants.adapter_name().to_string(), software_adapter: context.render_constants.is_software_adapter, ..Default::default() }; From 395ac3fe60a0f845c9766e6e18ef44f38af843fc Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:03:44 +0000 Subject: [PATCH 05/18] refactor(editor): use adapter_name accessor in playback benchmark --- crates/editor/examples/playback-pipeline-benchmark.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/editor/examples/playback-pipeline-benchmark.rs b/crates/editor/examples/playback-pipeline-benchmark.rs index 87d824e101..e59172ee9f 100644 --- a/crates/editor/examples/playback-pipeline-benchmark.rs +++ b/crates/editor/examples/playback-pipeline-benchmark.rs @@ -257,7 +257,7 @@ async fn run_full_pipeline_benchmark( println!( " GPU adapter: {} (software={})", - render_constants._adapter.get_info().name, + render_constants.adapter_name(), render_constants.is_software_adapter ); From eeabea6c7b65456d6d05ca3578df532e536529e4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:03:49 +0000 Subject: [PATCH 06/18] feat(cap-test): skip performance suite on Windows CI with software adapter --- crates/cap-test/src/suites/performance.rs | 82 +++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/crates/cap-test/src/suites/performance.rs b/crates/cap-test/src/suites/performance.rs index 52dae20c02..c109b920fe 100644 --- a/crates/cap-test/src/suites/performance.rs +++ b/crates/cap-test/src/suites/performance.rs @@ -44,6 +44,24 @@ pub async fn run_suite( let mut results = Vec::new(); let sample_duration_secs = duration.max(1); + if let Some(skipped_results) = probe_windows_ci_software_adapter(recording_path).await { + let summary = ResultsSummary::from_results(&skipped_results, start.elapsed()); + + return Ok(TestResults { + meta: ResultsMeta { + timestamp: Utc::now(), + config_name: "Performance Suite".to_string(), + config_path: Some(recording_path.display().to_string()), + platform: hardware.system_info.platform.clone(), + system: hardware.system_info.clone(), + cap_version: None, + }, + hardware: Some(hardware.clone()), + results: skipped_results, + summary, + }); + } + results.push(run_open_test(recording_path).await); results.push(run_playback_test(recording_path, sample_duration_secs).await); results.push(run_export_test(recording_path).await); @@ -65,6 +83,70 @@ pub async fn run_suite( }) } +#[cfg(target_os = "windows")] +async fn probe_windows_ci_software_adapter(recording_path: &Path) -> Option> { + if !std::env::var("GITHUB_ACTIONS") + .map(|value| value == "true") + .unwrap_or(false) + { + return None; + } + + let (is_software, adapter_name) = cap_rendering::probe_software_adapter().await?; + + if !is_software { + return None; + } + + let reason = format!( + "Windows GitHub Actions exposed software rendering via {adapter_name}; performance gate skipped because the runner cannot provide representative GPU metrics" + ); + let notes = vec![format!("adapter={adapter_name}")]; + + Some(vec![ + skipped_result( + "performance-open", + "Fixture Open", + fixture_config(recording_path), + &reason, + ¬es, + ), + skipped_result( + "performance-playback", + "Editor Playback", + fixture_config(recording_path), + &reason, + ¬es, + ), + skipped_result( + "performance-export", + "Export Startup And Throughput", + fixture_config(recording_path), + &reason, + ¬es, + ), + ]) +} + +#[cfg(not(target_os = "windows"))] +async fn probe_windows_ci_software_adapter(_recording_path: &Path) -> Option> { + None +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn skipped_result( + test_id: &str, + name: &str, + config: TestCaseConfig, + reason: &str, + notes: &[String], +) -> TestResult { + let mut result = TestResult::new(test_id.to_string(), name.to_string(), config); + result.set_skipped(reason); + result.notes.extend(notes.iter().cloned()); + result +} + struct FixtureContext { project: ProjectConfiguration, render_constants: Arc, From abc5fc1e584be9fbbdaafad9ee4fa0f2188fb59b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:03:57 +0000 Subject: [PATCH 07/18] feat(desktop): guard background tasks and window events during app exit --- apps/desktop/src-tauri/src/lib.rs | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 303f7425aa..d42a3ed326 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -194,6 +194,13 @@ fn spawn_exit_watchdog() { }); } +fn app_is_exiting(app: &AppHandle) -> bool { + match app.try_state::() { + Some(state) => state.is_exiting(), + None => false, + } +} + fn now_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -799,6 +806,10 @@ fn spawn_mic_error_handler(app_handle: AppHandle, error_rx: flume::Receiver = Vec::new(); let mut fast_loops = 0u32; loop { + if app_is_exiting(&app_handle) { + break; + } + let permissions = permissions::do_permissions_check(false); let cameras = if permissions.camera.permitted() { cap_camera::list_cameras().collect::>() @@ -935,6 +950,10 @@ fn spawn_devices_snapshot_emitter(app_handle: AppHandle) { }; fast_loops = fast_loops.saturating_add(1); tokio::time::sleep(dur).await; + + if app_is_exiting(&app_handle) { + break; + } } }); } @@ -1128,6 +1147,10 @@ fn spawn_microphone_watcher(app_handle: AppHandle) { let state = state.inner().clone(); loop { + if app_is_exiting(&app_handle) { + break; + } + let (should_check, label, is_marked) = { let guard = state.read().await; ( @@ -1185,6 +1208,10 @@ fn spawn_camera_watcher(app_handle: AppHandle) { let state = state.inner().clone(); loop { + if app_is_exiting(&app_handle) { + break; + } + let (should_check, camera_id, is_marked) = { let guard = state.read().await; ( @@ -3521,6 +3548,16 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { let label = window.label(); let app = window.app_handle(); + if matches!( + event, + WindowEvent::CloseRequested { .. } + | WindowEvent::Moved(_) + | WindowEvent::Focused(_) + ) && app_is_exiting(app) + { + return; + } + match event { WindowEvent::CloseRequested { api, .. } => { if let Ok(window_id) = CapWindowId::from_str(label) { @@ -3582,6 +3619,9 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { } } WindowEvent::Destroyed => { + if app_is_exiting(app) { + return; + } if let Ok(window_id) = CapWindowId::from_str(label) { if matches!(window_id, CapWindowId::Camera) { tracing::warn!("Camera window Destroyed event received!"); From 8601e08d22f811c896d02534a079b36828a80525 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:04:05 +0000 Subject: [PATCH 08/18] fix(desktop): replace unwrap with error propagation in editor commands --- apps/desktop/src-tauri/src/lib.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d42a3ed326..62df2e9cc4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1835,7 +1835,9 @@ struct SerializedEditorInstance { #[specta::specta] #[instrument(skip(window))] async fn create_editor_instance(window: Window) -> Result { - let CapWindowId::Editor { id } = CapWindowId::from_str(window.label()).unwrap() else { + let CapWindowId::Editor { id } = + CapWindowId::from_str(window.label()).map_err(|e| e.to_string())? + else { return Err("Invalid window".to_string()); }; @@ -1871,7 +1873,9 @@ async fn create_editor_instance(window: Window) -> Result Result { - let CapWindowId::Editor { id } = CapWindowId::from_str(window.label()).unwrap() else { + let CapWindowId::Editor { id } = + CapWindowId::from_str(window.label()).map_err(|e| e.to_string())? + else { return Err("Invalid window".to_string()); }; From dea74c1043798e0da84770040aba20c9adf9e80c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:04:09 +0000 Subject: [PATCH 09/18] fix(desktop): handle CapWindowId parse failure in dock icon visibility check --- apps/desktop/src-tauri/src/lib.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 62df2e9cc4..17fec13ad4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3746,10 +3746,11 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { if let Some(settings) = GeneralSettingsStore::get(app).unwrap_or(None) && settings.hide_dock_icon - && app - .webview_windows() - .keys() - .all(|label| !CapWindowId::from_str(label).unwrap().activates_dock()) + && app.webview_windows().keys().all(|label| { + CapWindowId::from_str(label) + .map(|id| !id.activates_dock()) + .unwrap_or(false) + }) { #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory) From 64ff8f2f0fbdbbd4c553e0f00611f5d73d2b3a4b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:04:13 +0000 Subject: [PATCH 10/18] refactor(desktop): use request_app_exit for tray quit action --- apps/desktop/src-tauri/src/tray.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index 3cd7cafbe6..e3d1687340 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -789,7 +789,10 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { }); } Ok(TrayItem::Quit) => { - app.exit(0); + let app = app.clone(); + tokio::spawn(async move { + crate::request_app_exit(app).await; + }); } Ok(TrayItem::PreviousItem(path)) => { handle_previous_item_click(app, &path); From 58a9018432a157e60faa1fc793077722a03b4062 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:04:16 +0000 Subject: [PATCH 11/18] feat(desktop): add window content protection diagnostics on Windows --- apps/desktop/src-tauri/src/windows.rs | 103 +++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 4fd0d5d5df..a3f24eefee 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -1503,6 +1503,9 @@ impl ShowCapWindow { } }; + #[cfg(target_os = "windows")] + log_window_content_protection(&window, should_protect, &title); + let camera_monitor = CapWindowId::Main .get(app) .map(|w| CursorMonitorInfo::from_window(&w)) @@ -1839,6 +1842,9 @@ impl ShowCapWindow { )) .build()?; + #[cfg(target_os = "windows")] + log_window_content_protection(&window, should_protect, &title); + let (pos_x, pos_y) = cursor_monitor.bottom_center_position(width, height, 120.0); let _ = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)); @@ -2194,6 +2200,98 @@ fn should_protect_window(app: &AppHandle, window_title: &str) -> bool { .unwrap_or_else(|| matches(&general_settings::default_excluded_windows())) } +#[cfg(target_os = "windows")] +fn cached_windows_version() -> Option<&'static scap_direct3d::WindowsVersion> { + static VERSION: std::sync::OnceLock> = + std::sync::OnceLock::new(); + VERSION + .get_or_init(scap_direct3d::WindowsVersion::detect) + .as_ref() +} + +#[cfg(target_os = "windows")] +fn display_affinity_name(value: u32) -> &'static str { + match value { + 0 => "WDA_NONE", + 1 => "WDA_MONITOR", + 17 => "WDA_EXCLUDEFROMCAPTURE", + _ => "UNKNOWN", + } +} + +#[cfg(target_os = "windows")] +fn log_window_content_protection(window: &WebviewWindow, enabled: bool, window_title: &str) { + use windows::Win32::UI::WindowsAndMessaging::{ + GetWindowDisplayAffinity, WDA_EXCLUDEFROMCAPTURE, WDA_NONE, + }; + + let expected = if enabled { + WDA_EXCLUDEFROMCAPTURE + } else { + WDA_NONE + }; + + if let Some(version) = cached_windows_version() + && enabled + && version.build < 19041 + { + warn!( + window = window.label(), + title = window_title, + version = %version.display_name(), + expected = display_affinity_name(expected.0), + "Window capture exclusion is not fully supported on this Windows build" + ); + } + + let hwnd = match window.hwnd() { + Ok(hwnd) => hwnd, + Err(error) => { + warn!( + window = window.label(), + title = window_title, + error = %error, + "Failed to get HWND for content protection diagnostics" + ); + return; + } + }; + + let mut applied = 0u32; + match unsafe { GetWindowDisplayAffinity(hwnd, &mut applied) } { + Ok(()) => { + if applied == expected.0 { + debug!( + window = window.label(), + title = window_title, + expected = display_affinity_name(expected.0), + applied = display_affinity_name(applied), + "Window content protection verified" + ); + } else { + warn!( + window = window.label(), + title = window_title, + expected = display_affinity_name(expected.0), + expected_raw = expected.0, + applied = display_affinity_name(applied), + applied_raw = applied, + "Window content protection mismatch" + ); + } + } + Err(error) => { + warn!( + window = window.label(), + title = window_title, + expected = display_affinity_name(expected.0), + error = %error, + "Failed to query window display affinity" + ); + } + } +} + #[tauri::command] #[specta::specta] #[instrument(skip(app))] @@ -2201,9 +2299,12 @@ pub fn refresh_window_content_protection(app: AppHandle) -> Result<(), Stri for (label, window) in app.webview_windows() { if let Ok(id) = CapWindowId::from_str(&label) { let title = id.title(); + let should_protect = should_protect_window(&app, &title); window - .set_content_protected(should_protect_window(&app, &title)) + .set_content_protected(should_protect) .map_err(|e| e.to_string())?; + #[cfg(target_os = "windows")] + log_window_content_protection(&window, should_protect, &title); } } From 7b9b7baf8b25a6d06431ebd3af07bda3a6e6211e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:35:11 +0000 Subject: [PATCH 12/18] Use Option.map to simplify shared device creation --- apps/desktop/src-tauri/src/lib.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 17fec13ad4..62ccfc1053 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -4077,17 +4077,13 @@ async fn create_editor_instance_impl( wait_for_recording_ready(&app, &path).await?; - let shared_device = if let Some(shared) = gpu_context::get_shared_gpu().await { - Some(cap_rendering::SharedWgpuDevice { - instance: (*shared.instance).clone(), - adapter: (*shared.adapter).clone(), - device: (*shared.device).clone(), - queue: (*shared.queue).clone(), - is_software_adapter: shared.is_software_adapter, - }) - } else { - None - }; + let shared_device = gpu_context::get_shared_gpu().await.map(|shared| cap_rendering::SharedWgpuDevice { + instance: (*shared.instance).clone(), + adapter: (*shared.adapter).clone(), + device: (*shared.device).clone(), + queue: (*shared.queue).clone(), + is_software_adapter: shared.is_software_adapter, + }); let instance = { let app = app.clone(); From 0a55be9515babc659889cd653434fa2eb45ad8fb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:35:25 +0000 Subject: [PATCH 13/18] fmt --- apps/desktop/src-tauri/src/lib.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 62ccfc1053..9918b2e0cd 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -4077,13 +4077,16 @@ async fn create_editor_instance_impl( wait_for_recording_ready(&app, &path).await?; - let shared_device = gpu_context::get_shared_gpu().await.map(|shared| cap_rendering::SharedWgpuDevice { - instance: (*shared.instance).clone(), - adapter: (*shared.adapter).clone(), - device: (*shared.device).clone(), - queue: (*shared.queue).clone(), - is_software_adapter: shared.is_software_adapter, - }); + let shared_device = + gpu_context::get_shared_gpu() + .await + .map(|shared| cap_rendering::SharedWgpuDevice { + instance: (*shared.instance).clone(), + adapter: (*shared.adapter).clone(), + device: (*shared.device).clone(), + queue: (*shared.queue).clone(), + is_software_adapter: shared.is_software_adapter, + }); let instance = { let app = app.clone(); From 9ba0f6db4dbc8f26c9497ffc7e5151cb949c1c15 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:38:02 +0000 Subject: [PATCH 14/18] clippy --- apps/desktop/src-tauri/src/windows.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index a3f24eefee..c0c43d552c 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -1463,6 +1463,9 @@ impl ShowCapWindow { app.set_activation_policy(tauri::ActivationPolicy::Accessory) .ok(); + let title = CapWindowId::Camera.title(); + let should_protect = should_protect_window(app, &title); + let mut window_builder = self .window_builder(app, "/camera") .maximized(false) @@ -2245,7 +2248,7 @@ fn log_window_content_protection(window: &WebviewWindow, enabled: bool, window_t } let hwnd = match window.hwnd() { - Ok(hwnd) => hwnd, + Ok(hwnd) => windows::Win32::Foundation::HWND(hwnd.0), Err(error) => { warn!( window = window.label(), From 1dce18ce372fcd8858c72b687f2b67d8003f5cc6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:16:52 +0000 Subject: [PATCH 15/18] Add null-safety and typing fixes across web --- .../async-video-code-reviews-page.test.ts | 4 +-- .../__tests__/unit/avi-to-mp4-page.test.ts | 14 ++++---- .../unit/best-screen-recorder-page.test.ts | 8 ++--- .../__tests__/unit/breadcrumb-schema.test.ts | 10 +++--- .../__tests__/unit/developer-actions.test.ts | 32 +++++++++++++++---- .../__tests__/unit/developer-api-auth.test.ts | 6 ++-- .../unit/developer-credit-math.test.ts | 2 ++ .../unit/developer-cron-storage.test.ts | 2 +- ...eveloper-documentation-videos-page.test.ts | 4 +-- apps/web/__tests__/unit/faq-schema.test.ts | 16 +++++----- ...aa-compliant-screen-recording-page.test.ts | 4 +-- apps/web/__tests__/unit/howto-schema.test.ts | 8 ++--- ...c-screen-recording-with-audio-page.test.ts | 4 +-- .../__tests__/unit/mov-to-mp4-page.test.ts | 14 ++++---- .../__tests__/unit/mp4-to-gif-page.test.ts | 14 ++++---- .../unit/obs-alternative-page.test.ts | 4 +-- .../open-source-screen-recorder-page.test.ts | 4 +-- .../unit/recording-spool-fallback.test.ts | 2 +- .../self-hosted-screen-recording-page.test.ts | 4 +-- .../unit/upload-progress-playback.test.ts | 4 +++ .../video-recording-software-page.test.ts | 4 +-- .../unit/video-speed-controller-page.test.ts | 14 ++++---- .../__tests__/unit/webm-to-mp4-page.test.ts | 14 ++++---- apps/web/actions/admin/replace-video.ts | 4 +-- .../Notifications/NotificationItem.tsx | 2 -- .../api/developer/sdk/v1/[...route]/upload.ts | 10 +++--- apps/web/app/embed/[videoId]/page.tsx | 1 + apps/web/app/s/[videoId]/page.tsx | 3 +- .../pages/HomePage/InstantModeDetail.tsx | 7 ++-- .../pages/HomePage/RecordingModePicker.tsx | 7 ++-- .../pages/HomePage/ScreenshotModeDetail.tsx | 3 +- .../pages/HomePage/StudioModeDetail.tsx | 1 + apps/web/lib/Notification.ts | 4 +-- 33 files changed, 134 insertions(+), 100 deletions(-) diff --git a/apps/web/__tests__/unit/async-video-code-reviews-page.test.ts b/apps/web/__tests__/unit/async-video-code-reviews-page.test.ts index 228252f517..73b8b34243 100644 --- a/apps/web/__tests__/unit/async-video-code-reviews-page.test.ts +++ b/apps/web/__tests__/unit/async-video-code-reviews-page.test.ts @@ -146,12 +146,12 @@ describe("AsyncVideoCodeReviewsPage FAQ schema", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "What is an async video code review?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); diff --git a/apps/web/__tests__/unit/avi-to-mp4-page.test.ts b/apps/web/__tests__/unit/avi-to-mp4-page.test.ts index 4761d8772e..6080963568 100644 --- a/apps/web/__tests__/unit/avi-to-mp4-page.test.ts +++ b/apps/web/__tests__/unit/avi-to-mp4-page.test.ts @@ -114,12 +114,12 @@ describe("AVI to MP4 FAQ schema validity", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "How do I convert AVI to MP4 online?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); @@ -169,11 +169,11 @@ describe("AVI to MP4 HowTo schema validity", () => { steps: howToSteps, }); - expect(schema.step[0].position).toBe(1); - expect(schema.step[0].name).toBe("Upload your AVI file"); - expect(schema.step[1].position).toBe(2); - expect(schema.step[2].position).toBe(3); - expect(schema.step[2].name).toBe("Download your MP4"); + expect(schema.step[0]!.position).toBe(1); + expect(schema.step[0]!.name).toBe("Upload your AVI file"); + expect(schema.step[1]!.position).toBe(2); + expect(schema.step[2]!.position).toBe(3); + expect(schema.step[2]!.name).toBe("Download your MP4"); }); it("produces JSON-serializable output", () => { diff --git a/apps/web/__tests__/unit/best-screen-recorder-page.test.ts b/apps/web/__tests__/unit/best-screen-recorder-page.test.ts index 88da5cf5ff..03e6f89d0e 100644 --- a/apps/web/__tests__/unit/best-screen-recorder-page.test.ts +++ b/apps/web/__tests__/unit/best-screen-recorder-page.test.ts @@ -138,20 +138,20 @@ describe("BestScreenRecorderPage FAQ schema", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "What is the best screen recorder?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); it("strips HTML tags from answers", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[1].acceptedAnswer.text).not.toContain(" { diff --git a/apps/web/__tests__/unit/breadcrumb-schema.test.ts b/apps/web/__tests__/unit/breadcrumb-schema.test.ts index faa4c03896..e86f5b37ec 100644 --- a/apps/web/__tests__/unit/breadcrumb-schema.test.ts +++ b/apps/web/__tests__/unit/breadcrumb-schema.test.ts @@ -35,8 +35,8 @@ describe("createBreadcrumbSchema", () => { name: "Tools", item: "https://cap.so/tools", }); - expect(schema.itemListElement[2].position).toBe(3); - expect(schema.itemListElement[2].name).toBe("Convert"); + expect(schema.itemListElement[2]!.position).toBe(3); + expect(schema.itemListElement[2]!.name).toBe("Convert"); }); it("omits item field when no url is provided", () => { @@ -45,8 +45,8 @@ describe("createBreadcrumbSchema", () => { { name: "Current Page" }, ]); - expect(schema.itemListElement[0].item).toBe("https://cap.so"); - expect("item" in schema.itemListElement[1]).toBe(false); + expect(schema.itemListElement[0]!.item).toBe("https://cap.so"); + expect("item" in schema.itemListElement[1]!).toBe(false); }); it("handles single item breadcrumb", () => { @@ -55,7 +55,7 @@ describe("createBreadcrumbSchema", () => { ]); expect(schema.itemListElement).toHaveLength(1); - expect(schema.itemListElement[0].position).toBe(1); + expect(schema.itemListElement[0]!.position).toBe(1); }); it("handles empty array", () => { diff --git a/apps/web/__tests__/unit/developer-actions.test.ts b/apps/web/__tests__/unit/developer-actions.test.ts index d2456de2d0..ed320e0d4a 100644 --- a/apps/web/__tests__/unit/developer-actions.test.ts +++ b/apps/web/__tests__/unit/developer-actions.test.ts @@ -86,11 +86,30 @@ vi.mock("drizzle-orm", () => ({ sql: vi.fn(), })); -import { __mockDb } from "@cap/database"; +import * as capDatabase from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; const mockGetCurrentUser = getCurrentUser as ReturnType; -const mockDb = __mockDb as Record>; + +type MockFn = ReturnType; +interface MockDb { + select: MockFn; + insert: MockFn; + update: MockFn; + delete: MockFn; + from: MockFn; + set: MockFn; + where: MockFn; + limit: MockFn; + values: MockFn; + transaction: MockFn; + leftJoin: MockFn; + orderBy: MockFn; + offset: MockFn; + [key: string]: MockFn | undefined; +} + +const mockDb = (capDatabase as unknown as { __mockDb: MockDb }).__mockDb; const mockUser = { id: "user-123", email: "test@example.com" }; const mockApp = { @@ -103,8 +122,9 @@ const mockApp = { function resetMockDb() { for (const key of Object.keys(mockDb)) { - if (typeof mockDb[key]?.mockClear === "function") { - mockDb[key].mockClear(); + const fn = mockDb[key]; + if (fn && typeof fn.mockClear === "function") { + fn.mockClear(); } } mockDb.select.mockReturnValue(mockDb); @@ -161,7 +181,7 @@ describe("createDeveloperApp", () => { }); expect(mockDb.insert).toHaveBeenCalled(); - const firstValuesCall = mockDb.values.mock.calls[0][0]; + const firstValuesCall = mockDb.values.mock.calls[0]![0]; expect(firstValuesCall).toMatchObject({ name: "My App", environment: "production", @@ -218,7 +238,7 @@ describe("createDeveloperApp", () => { }); const expectedPrefix = result.publicKey.slice(0, 12); - const keysValuesCall = mockDb.values.mock.calls[1][0]; + const keysValuesCall = mockDb.values.mock.calls[1]![0]; const publicKeyEntry = keysValuesCall.find( (entry: Record) => entry.keyType === "public", ); diff --git a/apps/web/__tests__/unit/developer-api-auth.test.ts b/apps/web/__tests__/unit/developer-api-auth.test.ts index 51bfbffe91..4e954f85f8 100644 --- a/apps/web/__tests__/unit/developer-api-auth.test.ts +++ b/apps/web/__tests__/unit/developer-api-auth.test.ts @@ -75,7 +75,7 @@ describe("developer API auth - key format validation", () => { }); it("rejects undefined via optional chaining", () => { - const key: string | undefined = undefined; + const key = undefined as string | undefined; expect(key?.startsWith("cpk_")).toBeFalsy(); }); @@ -107,7 +107,7 @@ describe("developer API auth - key format validation", () => { }); it("rejects undefined via optional chaining", () => { - const key: string | undefined = undefined; + const key = undefined as string | undefined; expect(key?.startsWith("csk_")).toBeFalsy(); }); @@ -132,7 +132,7 @@ describe("developer API auth - bearer token extraction", () => { }); it("returns undefined when authorization header is missing", () => { - const header: string | undefined = undefined; + const header = undefined as string | undefined; const token = header?.split(" ")[1]; expect(token).toBeUndefined(); }); diff --git a/apps/web/__tests__/unit/developer-credit-math.test.ts b/apps/web/__tests__/unit/developer-credit-math.test.ts index 952f101cca..06188dba50 100644 --- a/apps/web/__tests__/unit/developer-credit-math.test.ts +++ b/apps/web/__tests__/unit/developer-credit-math.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from "vitest"; + const MICRO_CREDITS_PER_DOLLAR = 100_000; const MICRO_CREDITS_PER_MINUTE = 5_000; const MICRO_CREDITS_PER_MINUTE_PER_DAY = 3.33; diff --git a/apps/web/__tests__/unit/developer-cron-storage.test.ts b/apps/web/__tests__/unit/developer-cron-storage.test.ts index 36efa4bab2..05348226a1 100644 --- a/apps/web/__tests__/unit/developer-cron-storage.test.ts +++ b/apps/web/__tests__/unit/developer-cron-storage.test.ts @@ -111,7 +111,7 @@ function setupDbSequence( let callIndex = 0; mockDb.mockImplementation(() => { const idx = callIndex++; - const result = idx < responses.length ? responses[idx] : []; + const result = idx < responses.length ? responses[idx]! : []; return makeChain(result, txOptions); }); } diff --git a/apps/web/__tests__/unit/developer-documentation-videos-page.test.ts b/apps/web/__tests__/unit/developer-documentation-videos-page.test.ts index b90272941b..4b824aa390 100644 --- a/apps/web/__tests__/unit/developer-documentation-videos-page.test.ts +++ b/apps/web/__tests__/unit/developer-documentation-videos-page.test.ts @@ -155,12 +155,12 @@ describe("DeveloperDocumentationVideosPage FAQ schema", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "What is a developer documentation video?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); diff --git a/apps/web/__tests__/unit/faq-schema.test.ts b/apps/web/__tests__/unit/faq-schema.test.ts index 67f703da9d..c5ff0d4f61 100644 --- a/apps/web/__tests__/unit/faq-schema.test.ts +++ b/apps/web/__tests__/unit/faq-schema.test.ts @@ -32,7 +32,7 @@ describe("createFAQSchema", () => { text: "Yes, Cap is free.", }, }); - expect(schema.mainEntity[1].name).toBe("What platforms does Cap support?"); + expect(schema.mainEntity[1]!.name).toBe("What platforms does Cap support?"); expect(schema.mainEntity).toHaveLength(2); }); @@ -46,10 +46,10 @@ describe("createFAQSchema", () => { ]; const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0].acceptedAnswer.text).toBe( + expect(schema.mainEntity[0]!.acceptedAnswer.text).toBe( "Visit the download page to get started.", ); - expect(schema.mainEntity[0].acceptedAnswer.text).not.toContain(" { @@ -62,7 +62,7 @@ describe("createFAQSchema", () => { ]; const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0].acceptedAnswer.text).toBe( + expect(schema.mainEntity[0]!.acceptedAnswer.text).toBe( "Cap offers 4K recording, instant sharing, and affordable pricing.", ); }); @@ -127,8 +127,8 @@ describe("createFAQSchema", () => { expect(schema["@type"]).toBe("FAQPage"); expect(schema.mainEntity).toHaveLength(5); - expect(schema.mainEntity[3].acceptedAnswer.text).not.toContain(" { @@ -162,8 +162,8 @@ describe("createFAQSchema", () => { expect(schema["@type"]).toBe("FAQPage"); expect(schema.mainEntity).toHaveLength(5); - expect(schema.mainEntity[0].acceptedAnswer.text).not.toContain(" { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "Can Cap be used for HIPAA-compliant screen recording?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); diff --git a/apps/web/__tests__/unit/howto-schema.test.ts b/apps/web/__tests__/unit/howto-schema.test.ts index 2ee3de6a19..ac7ffda3f0 100644 --- a/apps/web/__tests__/unit/howto-schema.test.ts +++ b/apps/web/__tests__/unit/howto-schema.test.ts @@ -34,8 +34,8 @@ describe("createHowToSchema", () => { name: "Download", text: "Download the app.", }); - expect(schema.step[1].position).toBe(2); - expect(schema.step[2].position).toBe(3); + expect(schema.step[1]!.position).toBe(2); + expect(schema.step[2]!.position).toBe(3); }); it("uses default totalTime of PT2M when not provided", () => { @@ -124,7 +124,7 @@ describe("createHowToSchema", () => { expect(schema["@type"]).toBe("HowTo"); expect(schema.step).toHaveLength(4); - expect(schema.step[0].name).toBe("Download and install Cap"); - expect(schema.step[3].position).toBe(4); + expect(schema.step[0]!.name).toBe("Download and install Cap"); + expect(schema.step[3]!.position).toBe(4); }); }); diff --git a/apps/web/__tests__/unit/mac-screen-recording-with-audio-page.test.ts b/apps/web/__tests__/unit/mac-screen-recording-with-audio-page.test.ts index 013d057fd7..288df52719 100644 --- a/apps/web/__tests__/unit/mac-screen-recording-with-audio-page.test.ts +++ b/apps/web/__tests__/unit/mac-screen-recording-with-audio-page.test.ts @@ -171,12 +171,12 @@ describe("MacScreenRecordingWithAudioPage FAQ schema", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "Why doesn't macOS record internal audio by default?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); diff --git a/apps/web/__tests__/unit/mov-to-mp4-page.test.ts b/apps/web/__tests__/unit/mov-to-mp4-page.test.ts index e2feb8eafc..6025972b7d 100644 --- a/apps/web/__tests__/unit/mov-to-mp4-page.test.ts +++ b/apps/web/__tests__/unit/mov-to-mp4-page.test.ts @@ -109,12 +109,12 @@ describe("MOV to MP4 FAQ schema validity", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "How do I convert MOV to MP4 online?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); @@ -164,11 +164,11 @@ describe("MOV to MP4 HowTo schema validity", () => { steps: howToSteps, }); - expect(schema.step[0].position).toBe(1); - expect(schema.step[0].name).toBe("Upload your MOV file"); - expect(schema.step[1].position).toBe(2); - expect(schema.step[2].position).toBe(3); - expect(schema.step[2].name).toBe("Download your MP4"); + expect(schema.step[0]!.position).toBe(1); + expect(schema.step[0]!.name).toBe("Upload your MOV file"); + expect(schema.step[1]!.position).toBe(2); + expect(schema.step[2]!.position).toBe(3); + expect(schema.step[2]!.name).toBe("Download your MP4"); }); it("produces JSON-serializable output", () => { diff --git a/apps/web/__tests__/unit/mp4-to-gif-page.test.ts b/apps/web/__tests__/unit/mp4-to-gif-page.test.ts index 9059c134a6..b0f54ff773 100644 --- a/apps/web/__tests__/unit/mp4-to-gif-page.test.ts +++ b/apps/web/__tests__/unit/mp4-to-gif-page.test.ts @@ -109,12 +109,12 @@ describe("MP4 to GIF FAQ schema validity", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "How do I convert MP4 to GIF?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); @@ -164,11 +164,11 @@ describe("MP4 to GIF HowTo schema validity", () => { steps: howToSteps, }); - expect(schema.step[0].position).toBe(1); - expect(schema.step[0].name).toBe("Upload your MP4 file"); - expect(schema.step[1].position).toBe(2); - expect(schema.step[2].position).toBe(3); - expect(schema.step[2].name).toBe("Convert and download your GIF"); + expect(schema.step[0]!.position).toBe(1); + expect(schema.step[0]!.name).toBe("Upload your MP4 file"); + expect(schema.step[1]!.position).toBe(2); + expect(schema.step[2]!.position).toBe(3); + expect(schema.step[2]!.name).toBe("Convert and download your GIF"); }); it("produces JSON-serializable output", () => { diff --git a/apps/web/__tests__/unit/obs-alternative-page.test.ts b/apps/web/__tests__/unit/obs-alternative-page.test.ts index 92af320dc5..30697d4d3b 100644 --- a/apps/web/__tests__/unit/obs-alternative-page.test.ts +++ b/apps/web/__tests__/unit/obs-alternative-page.test.ts @@ -148,12 +148,12 @@ describe("ObsAlternativePage FAQ schema", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "Why would I use Cap instead of OBS Studio?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); diff --git a/apps/web/__tests__/unit/open-source-screen-recorder-page.test.ts b/apps/web/__tests__/unit/open-source-screen-recorder-page.test.ts index 6aa9a40096..13c69b0baa 100644 --- a/apps/web/__tests__/unit/open-source-screen-recorder-page.test.ts +++ b/apps/web/__tests__/unit/open-source-screen-recorder-page.test.ts @@ -144,12 +144,12 @@ describe("OpenSourceScreenRecorderPage FAQ schema", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "Is Cap really open source?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); diff --git a/apps/web/__tests__/unit/recording-spool-fallback.test.ts b/apps/web/__tests__/unit/recording-spool-fallback.test.ts index f6d7cc34a4..2fc41b24db 100644 --- a/apps/web/__tests__/unit/recording-spool-fallback.test.ts +++ b/apps/web/__tests__/unit/recording-spool-fallback.test.ts @@ -7,7 +7,7 @@ const blobToText = async (blob: Blob) => describe("moveRecordingSpoolToInMemoryBackup", () => { it("merges recovered chunks with later in-memory chunks without duplicating them", async () => { let retainedChunks = [new Blob(["older"], { type: "video/webm" })]; - let releaseRecovery: (() => void) | null = null; + let releaseRecovery = null as (() => void) | null; const replaceLocalRecording = vi.fn((chunks: Blob[]) => { retainedChunks = chunks; diff --git a/apps/web/__tests__/unit/self-hosted-screen-recording-page.test.ts b/apps/web/__tests__/unit/self-hosted-screen-recording-page.test.ts index 7355a022f6..0184435dae 100644 --- a/apps/web/__tests__/unit/self-hosted-screen-recording-page.test.ts +++ b/apps/web/__tests__/unit/self-hosted-screen-recording-page.test.ts @@ -147,12 +147,12 @@ describe("SelfHostedScreenRecordingPage FAQ schema", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "Can Cap be self-hosted?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); diff --git a/apps/web/__tests__/unit/upload-progress-playback.test.ts b/apps/web/__tests__/unit/upload-progress-playback.test.ts index 16c17b8779..649494b136 100644 --- a/apps/web/__tests__/unit/upload-progress-playback.test.ts +++ b/apps/web/__tests__/unit/upload-progress-playback.test.ts @@ -52,6 +52,7 @@ describe("shouldDeferPlaybackSource", () => { status: "error", lastUpdated: new Date(), errorMessage: "Video processing timed out", + hasRawFallback: false, }, true, ), @@ -62,6 +63,7 @@ describe("shouldDeferPlaybackSource", () => { status: "error", lastUpdated: new Date(), errorMessage: "Video processing timed out", + hasRawFallback: false, }, false, ), @@ -84,6 +86,7 @@ describe("shouldDeferPlaybackSource", () => { status: "error", lastUpdated: new Date(), errorMessage: "Video uploaded, but processing could not start.", + hasRawFallback: false, }, true, ), @@ -94,6 +97,7 @@ describe("shouldDeferPlaybackSource", () => { status: "error", lastUpdated: new Date(), errorMessage: null, + hasRawFallback: false, }, false, ), diff --git a/apps/web/__tests__/unit/video-recording-software-page.test.ts b/apps/web/__tests__/unit/video-recording-software-page.test.ts index 843220c4f2..85d29d1b6a 100644 --- a/apps/web/__tests__/unit/video-recording-software-page.test.ts +++ b/apps/web/__tests__/unit/video-recording-software-page.test.ts @@ -143,12 +143,12 @@ describe("VideoRecordingSoftwarePage FAQ schema", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "What is video recording software?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); diff --git a/apps/web/__tests__/unit/video-speed-controller-page.test.ts b/apps/web/__tests__/unit/video-speed-controller-page.test.ts index 909a156e86..e11982c6be 100644 --- a/apps/web/__tests__/unit/video-speed-controller-page.test.ts +++ b/apps/web/__tests__/unit/video-speed-controller-page.test.ts @@ -109,12 +109,12 @@ describe("Video Speed Controller FAQ schema validity", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "How do I change the speed of a video online?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); @@ -164,11 +164,11 @@ describe("Video Speed Controller HowTo schema validity", () => { steps: howToSteps, }); - expect(schema.step[0].position).toBe(1); - expect(schema.step[0].name).toBe("Upload your video file"); - expect(schema.step[1].position).toBe(2); - expect(schema.step[2].position).toBe(3); - expect(schema.step[2].name).toBe("Process and download your video"); + expect(schema.step[0]!.position).toBe(1); + expect(schema.step[0]!.name).toBe("Upload your video file"); + expect(schema.step[1]!.position).toBe(2); + expect(schema.step[2]!.position).toBe(3); + expect(schema.step[2]!.name).toBe("Process and download your video"); }); it("produces JSON-serializable output", () => { diff --git a/apps/web/__tests__/unit/webm-to-mp4-page.test.ts b/apps/web/__tests__/unit/webm-to-mp4-page.test.ts index ac8cc998fe..e76be34f95 100644 --- a/apps/web/__tests__/unit/webm-to-mp4-page.test.ts +++ b/apps/web/__tests__/unit/webm-to-mp4-page.test.ts @@ -114,12 +114,12 @@ describe("WebM to MP4 FAQ schema validity", () => { it("maps each FAQ to a Question entity with acceptedAnswer", () => { const schema = createFAQSchema(faqs); - expect(schema.mainEntity[0]).toEqual({ + expect(schema.mainEntity[0]!).toEqual({ "@type": "Question", name: "How do I convert WebM to MP4 online?", acceptedAnswer: { "@type": "Answer", - text: faqs[0].answer, + text: faqs[0]!.answer, }, }); }); @@ -169,11 +169,11 @@ describe("WebM to MP4 HowTo schema validity", () => { steps: howToSteps, }); - expect(schema.step[0].position).toBe(1); - expect(schema.step[0].name).toBe("Upload your WebM file"); - expect(schema.step[1].position).toBe(2); - expect(schema.step[2].position).toBe(3); - expect(schema.step[2].name).toBe("Download your MP4"); + expect(schema.step[0]!.position).toBe(1); + expect(schema.step[0]!.name).toBe("Upload your WebM file"); + expect(schema.step[1]!.position).toBe(2); + expect(schema.step[2]!.position).toBe(3); + expect(schema.step[2]!.name).toBe("Download your MP4"); }); it("produces JSON-serializable output", () => { diff --git a/apps/web/actions/admin/replace-video.ts b/apps/web/actions/admin/replace-video.ts index 09dd139186..d7af00742e 100644 --- a/apps/web/actions/admin/replace-video.ts +++ b/apps/web/actions/admin/replace-video.ts @@ -9,7 +9,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import { AwsCredentials, S3Buckets } from "@cap/web-backend"; -import { S3Bucket } from "@cap/web-domain"; +import { S3Bucket, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; @@ -32,7 +32,7 @@ async function getVideoOrThrow(videoId: string) { bucket: videos.bucket, }) .from(videos) - .where(eq(videos.id, videoId)); + .where(eq(videos.id, Video.VideoId.make(videoId))); if (!video) { throw new Error("Video not found"); diff --git a/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx b/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx index 1bdcdb202e..8db9fa5b95 100644 --- a/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx +++ b/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx @@ -126,7 +126,5 @@ function getLink(notification: APINotification) { case "view": case "anon_view": return `/s/${notification.videoId}`; - default: - return `/s/${notification.videoId}`; } } diff --git a/apps/web/app/api/developer/sdk/v1/[...route]/upload.ts b/apps/web/app/api/developer/sdk/v1/[...route]/upload.ts index ef8017944b..79cfe9a602 100644 --- a/apps/web/app/api/developer/sdk/v1/[...route]/upload.ts +++ b/apps/web/app/api/developer/sdk/v1/[...route]/upload.ts @@ -8,7 +8,7 @@ import { import { provideOptionalAuth, S3Buckets } from "@cap/web-backend"; import { zValidator } from "@hono/zod-validator"; import { and, eq, sql } from "drizzle-orm"; -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { Hono } from "hono"; import { z } from "zod"; import { runPromise } from "@/lib/server"; @@ -73,7 +73,7 @@ app.post( try { const uploadId = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(); + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); const { UploadId } = yield* bucket.multipart.create(s3Key, { ContentType: resolvedContentType, CacheControl: "max-age=31536000", @@ -126,7 +126,7 @@ app.post( try { const presignedUrl = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(); + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); return yield* bucket.multipart.getPresignedUploadPartUrl( s3Key, uploadId, @@ -268,7 +268,7 @@ app.post( } await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(); + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); const sortedParts = [...parts].sort( (a, b) => a.partNumber - b.partNumber, @@ -338,7 +338,7 @@ app.post( try { await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(); + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); yield* bucket.multipart.abort(s3Key, uploadId); }).pipe(provideOptionalAuth, runPromise); diff --git a/apps/web/app/embed/[videoId]/page.tsx b/apps/web/app/embed/[videoId]/page.tsx index 1795171798..ee56bf3e67 100644 --- a/apps/web/app/embed/[videoId]/page.tsx +++ b/apps/web/app/embed/[videoId]/page.tsx @@ -146,6 +146,7 @@ export default async function EmbedVideoPage( height: videos.height, duration: videos.duration, fps: videos.fps, + firstViewEmailSentAt: videos.firstViewEmailSentAt, hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), sharedOrganization: { organizationId: sharedVideos.organizationId, diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 6eafdca3c0..8741a78e83 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -118,7 +118,7 @@ const ALLOWED_REFERRERS = [ function PolicyDeniedView({ reason }: { reason?: string }) { let title = "This video is private"; - let description = ( + let description: React.ReactNode = ( <> If you own this video, please sign in to manage sharing. @@ -329,6 +329,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { height: videos.height, duration: videos.duration, fps: videos.fps, + firstViewEmailSentAt: videos.firstViewEmailSentAt, hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), sharedOrganization: { organizationId: sharedVideos.organizationId, diff --git a/apps/web/components/pages/HomePage/InstantModeDetail.tsx b/apps/web/components/pages/HomePage/InstantModeDetail.tsx index ebd3887eb4..f67b1fdd04 100644 --- a/apps/web/components/pages/HomePage/InstantModeDetail.tsx +++ b/apps/web/components/pages/HomePage/InstantModeDetail.tsx @@ -112,7 +112,7 @@ const MockSharePage = () => { const observer = new IntersectionObserver( ([entry]) => { - if (entry.isIntersecting) { + if (entry?.isIntersecting) { if (!videoLoadedRef.current) { video.src = "/illustrations/homepage-animation.mp4"; videoLoadedRef.current = true; @@ -138,7 +138,10 @@ const MockSharePage = () => { let index = 0; const interval = setInterval(() => { index = (index + 1) % TABS.length; - setActiveTab(TABS[index]); + const nextTab = TABS[index]; + if (nextTab) { + setActiveTab(nextTab); + } }, 3000); return () => clearInterval(interval); }, [tabInteracted]); diff --git a/apps/web/components/pages/HomePage/RecordingModePicker.tsx b/apps/web/components/pages/HomePage/RecordingModePicker.tsx index 25c2b4e106..4f511de47e 100644 --- a/apps/web/components/pages/HomePage/RecordingModePicker.tsx +++ b/apps/web/components/pages/HomePage/RecordingModePicker.tsx @@ -101,7 +101,8 @@ const RecordingModePicker = () => { const interval = setInterval(() => { setSelected((prev) => { const currentIndex = modes.findIndex((m) => m.id === prev); - return modes[(currentIndex + 1) % modes.length].id; + const nextMode = modes[(currentIndex + 1) % modes.length]; + return nextMode ? nextMode.id : prev; }); }, AUTO_CYCLE_INTERVAL); @@ -114,7 +115,9 @@ const RecordingModePicker = () => { const observer = new IntersectionObserver( ([entry]) => { - setIsInView(entry.isIntersecting); + if (entry) { + setIsInView(entry.isIntersecting); + } }, { threshold: 0.3 }, ); diff --git a/apps/web/components/pages/HomePage/ScreenshotModeDetail.tsx b/apps/web/components/pages/HomePage/ScreenshotModeDetail.tsx index 22ffd6e50a..5fb6146f70 100644 --- a/apps/web/components/pages/HomePage/ScreenshotModeDetail.tsx +++ b/apps/web/components/pages/HomePage/ScreenshotModeDetail.tsx @@ -374,7 +374,7 @@ const MockScreenshotEditor = () => { const observer = new IntersectionObserver( ([entry]) => { - if (entry.isIntersecting && !isInView) { + if (entry?.isIntersecting && !isInView) { setIsInView(true); } }, @@ -413,6 +413,7 @@ const MockScreenshotEditor = () => { if (cancelled) return; const next = (current + 1) % AUTO_CONFIGS.length; const cfg = AUTO_CONFIGS[next]; + if (!cfg) return; setGradientIndex(cfg.gradientIndex); setPadding(cfg.padding); setRounded(cfg.rounded); diff --git a/apps/web/components/pages/HomePage/StudioModeDetail.tsx b/apps/web/components/pages/HomePage/StudioModeDetail.tsx index 34a5642c42..e8b39f71e2 100644 --- a/apps/web/components/pages/HomePage/StudioModeDetail.tsx +++ b/apps/web/components/pages/HomePage/StudioModeDetail.tsx @@ -283,6 +283,7 @@ const MockEditor = () => { if (cancelled) return; const next = (current + 1) % AUTO_CONFIGS.length; const cfg = AUTO_CONFIGS[next]; + if (!cfg) return; setGradientIndex(cfg.gradientIndex); setPadding(cfg.padding); setRounded(cfg.rounded); diff --git a/apps/web/lib/Notification.ts b/apps/web/lib/Notification.ts index 9b6cd95584..b5d6989bde 100644 --- a/apps/web/lib/Notification.ts +++ b/apps/web/lib/Notification.ts @@ -5,7 +5,7 @@ import { nanoId } from "@cap/database/helpers"; import { comments, notifications, users, videos } from "@cap/database/schema"; import { buildEnv, serverEnv } from "@cap/env"; import type { Notification, NotificationBase } from "@cap/web-api-contract"; -import { type Comment, Video } from "@cap/web-domain"; +import { type Comment, User, Video } from "@cap/web-domain"; import { and, eq, gte, isNull, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import type { UserPreferences } from "@/app/(org)/dashboard/dashboard-data"; @@ -368,7 +368,7 @@ export async function sendFirstViewEmail( const [viewer] = await database .select({ name: users.name, email: users.email }) .from(users) - .where(eq(users.id, params.viewerUserId)) + .where(eq(users.id, User.UserId.make(params.viewerUserId))) .limit(1); viewerName = viewer?.name || viewer?.email || "Someone"; } From 6b1308b5bba5ee26081f628501fbba63e54bb59c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:59:37 +0000 Subject: [PATCH 16/18] Address PR review comments for wgpu instance, camera protection, and adapter fallback --- apps/desktop/src-tauri/src/screenshot_editor.rs | 2 +- apps/desktop/src-tauri/src/windows.rs | 1 + crates/rendering/src/lib.rs | 15 +++++++++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index 339514e272..6dec290f6f 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -258,7 +258,7 @@ impl ScreenshotEditorInstances { is_software_adapter: gpu.is_software_adapter, } } else { - let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); + let instance = cap_rendering::create_wgpu_instance().await; let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index c0c43d552c..d04e8039e4 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -1483,6 +1483,7 @@ impl ShowCapWindow { ", state.camera_ws_port, centered )) + .content_protected(should_protect) .transparent(true) .visible(false); diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 36c2dde8cb..d7dd830cc7 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -89,14 +89,25 @@ pub async fn create_wgpu_instance() -> wgpu::Instance { pub async fn probe_software_adapter() -> Option<(bool, String)> { let instance = create_wgpu_instance().await; - let adapter = instance + let adapter = match instance .request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, force_fallback_adapter: false, compatible_surface: None, }) .await - .ok()?; + .ok() + { + Some(adapter) => adapter, + None => instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + force_fallback_adapter: true, + compatible_surface: None, + }) + .await + .ok()?, + }; let info = adapter.get_info(); Some((is_software_wgpu_adapter(&info), info.name)) } From 07c97ac44f7621fbf8e902c6f933a4c9ac7e2e37 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:13:41 +0000 Subject: [PATCH 17/18] Add fallback return in getLink for forward-compatible notification types --- .../dashboard/_components/Notifications/NotificationItem.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx b/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx index 8db9fa5b95..152ea141c6 100644 --- a/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx +++ b/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx @@ -127,4 +127,6 @@ function getLink(notification: APINotification) { case "anon_view": return `/s/${notification.videoId}`; } + + return `/s/${notification.videoId}`; } From f11755c63364ae2cdb1b207e5ebdee9261ec74b2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:13:41 +0000 Subject: [PATCH 18/18] Add fallback return in getLink for forward-compatible notification types --- .../dashboard/_components/Notifications/NotificationItem.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx b/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx index 8db9fa5b95..c686c63101 100644 --- a/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx +++ b/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx @@ -127,4 +127,6 @@ function getLink(notification: APINotification) { case "anon_view": return `/s/${notification.videoId}`; } + + return `/s/${(notification as APINotification).videoId}`; }