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) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 303f7425aa..9918b2e0cd 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; ( @@ -1808,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()); }; @@ -1844,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()); }; @@ -3521,6 +3552,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 +3623,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!"); @@ -3702,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) @@ -4032,17 +4077,16 @@ 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(); diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index 1e539dd44e..6dec290f6f 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 = cap_rendering::create_wgpu_instance().await; + 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()); 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); diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 4fd0d5d5df..d04e8039e4 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) @@ -1480,6 +1483,7 @@ impl ShowCapWindow { ", state.camera_ws_port, centered )) + .content_protected(should_protect) .transparent(true) .visible(false); @@ -1503,6 +1507,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 +1846,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 +2204,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) => windows::Win32::Foundation::HWND(hwnd.0), + 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 +2303,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); } } 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..c686c63101 100644 --- a/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx +++ b/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx @@ -126,7 +126,7 @@ function getLink(notification: APINotification) { case "view": case "anon_view": return `/s/${notification.videoId}`; - default: - return `/s/${notification.videoId}`; } + + return `/s/${(notification as APINotification).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"; } diff --git a/crates/cap-test/src/suites/performance.rs b/crates/cap-test/src/suites/performance.rs index d44d064076..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, @@ -126,10 +208,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 +426,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() }; 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 ); diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index ce1ceaf476..d7dd830cc7 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -50,6 +50,68 @@ 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 = match instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + }) + .await + .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)) +} + const STANDARD_CURSOR_HEIGHT: f32 = 75.0; fn rounding_type_value(style: CornerStyle) -> f32 { @@ -990,6 +1052,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 +1082,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 +1094,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 +1138,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 +1149,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 +1181,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 +1216,7 @@ impl RenderVideoConstants { meta, recording_meta, is_software_adapter, + adapter_name, }) } }