diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index ac95fae0244..0752f5cd290 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -92,7 +92,10 @@ impl MainWindowRecordingStartBehaviour { #[cfg(windows)] return window.minimize(); #[cfg(not(windows))] - window.hide() + { + crate::hide_main_window(window.app_handle()); + Ok(()) + } } Self::Minimise => window.minimize(), } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ce0094df8cc..0cb082fc27d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1750,6 +1750,12 @@ async fn get_devices_snapshot() -> DevicesUpdated { } } +fn any_webview_window_visible(app: &AppHandle) -> bool { + app.webview_windows() + .values() + .any(|window| window.is_visible().unwrap_or(false)) +} + fn spawn_devices_snapshot_emitter(app_handle: AppHandle) { tokio::spawn(async move { let mut last_perm_tuple: (u8, u8, u8, u8) = (255, 255, 255, 255); @@ -1766,6 +1772,14 @@ fn spawn_devices_snapshot_emitter(app_handle: AppHandle) { continue; } + // Device snapshots only feed UI pickers via DevicesUpdated, so + // polling while every window sits hidden in the tray probes the + // OS device stack and burns CPU for nobody (#2132). + if !any_webview_window_visible(&app_handle) { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + let permissions = permissions::do_permissions_check(false); let Some((cameras, microphones)) = collect_device_inventory( || app_is_exiting(&app_handle), @@ -4739,9 +4753,7 @@ pub async fn open_target_picker( ) { use tauri::Manager; - if let Some(window) = CapWindowId::Main.get(app) { - window.hide().ok(); - } + hide_main_window(app); let state = app.state::(); let display_id = None; @@ -5644,7 +5656,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { } CapWindowId::Main => { api.prevent_close(); - let _ = window.hide(); + hide_main_window(app); #[cfg(target_os = "macos")] crate::permissions::schedule_macos_dock_visibility_sync(app); @@ -6798,9 +6810,15 @@ fn show_import_error_dialog(app: &AppHandle, message: String) { .show(|_| {}); } -fn hide_main_window(app: &AppHandle) { - if let Some(main_window) = CapWindowId::Main.get(app) { - let _ = main_window.hide(); +// Hidden webviews on Windows never see document.visibilityState change +// (tauri-apps/tauri#9524), so the frontend cannot detect hide-to-tray on its +// own; this event lets it pause polling, and only fires when the hide +// actually happened so a failed hide never pauses a visible window (#2132). +pub(crate) fn hide_main_window(app: &AppHandle) { + if let Some(main_window) = CapWindowId::Main.get(app) + && main_window.hide().is_ok() + { + let _ = main_window.emit_to(CapWindowId::Main.label(), "main-window-hidden", ()); } } @@ -6880,9 +6898,7 @@ fn open_project_from_path(path: &Path, app: AppHandle) -> Result<(), String> { let _ = app .opener() .open_path(mp4_path.to_str().unwrap_or_default(), None::); - if let Some(main_window) = CapWindowId::Main.get(&app) { - main_window.hide().ok(); - } + hide_main_window(&app); } } } diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 5deba345c4e..3fc30b30adc 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -163,6 +163,8 @@ fn hide_recording_windows(app: &AppHandle, restore_target_select_overlays: bool) focus_manager.remember_overlay_for_restore(label); } hide_overlay(&window); + } else if matches!(id, CapWindowId::Main) { + crate::hide_main_window(app); } else { let _ = window.hide(); } @@ -1515,7 +1517,7 @@ impl ShowCapWindow { init_target_mode: Some(target_mode), } = self { - window.hide().ok(); + crate::hide_main_window(app); emit_app_event( app, RequestSetTargetMode { @@ -2044,9 +2046,7 @@ impl ShowCapWindow { window } Self::Upgrade => { - if let Some(main) = CapWindowId::Main.get(app) { - let _ = main.hide(); - } + crate::hide_main_window(app); let window = self .window_builder(app, "/upgrade") @@ -2080,9 +2080,7 @@ impl ShowCapWindow { window } Self::ModeSelect => { - if let Some(main) = CapWindowId::Main.get(app) { - let _ = main.hide(); - } + crate::hide_main_window(app); let window = self .window_builder(app, "/mode-select") @@ -2116,9 +2114,7 @@ impl ShowCapWindow { window } Self::Onboarding => { - if let Some(main) = CapWindowId::Main.get(app) { - let _ = main.hide(); - } + crate::hide_main_window(app); let width = (cursor_monitor.width * 0.58).clamp(860.0, 1080.0); let height = (width * 0.72).clamp(690.0, 780.0); diff --git a/apps/desktop/src/app.tsx b/apps/desktop/src/app.tsx index be56bd5ec9b..7dc46a86a00 100644 --- a/apps/desktop/src/app.tsx +++ b/apps/desktop/src/app.tsx @@ -1,5 +1,9 @@ import { Route, Router, useCurrentMatches } from "@solidjs/router"; -import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"; +import { + focusManager, + QueryClient, + QueryClientProvider, +} from "@tanstack/solid-query"; import { getCurrentWebviewWindow, type WebviewWindow, @@ -124,6 +128,7 @@ export default function App() { function Inner() { const currentWindow = getCurrentWebviewWindow(); createThemeListener(currentWindow); + createHiddenWindowQueryPause(currentWindow); onMount(() => { initAnonymousUser(); @@ -288,6 +293,46 @@ function prewarmFontCaches() { else setTimeout(warm, 250); } +// Hidden Tauri windows never flip document.visibilityState on Windows +// (tauri-apps/tauri#9524), so TanStack keeps every refetchInterval firing +// while the app idles in the tray (#2132). Pause queries when the backend +// hides the window; on focus, hand control back to TanStack's own +// visibilitychange detection (setFocused(undefined)) so platforms where it +// works, like macOS minimize, keep pausing natively. +function createHiddenWindowQueryPause(currentWindow: WebviewWindow) { + if (currentWindow.label !== "main") return; + + let focusGeneration = 0; + + const unlisteners = [ + currentWindow.listen("main-window-hidden", () => { + focusManager.setFocused(false); + }), + currentWindow.onFocusChanged((event) => { + focusGeneration += 1; + if (event.payload) { + focusManager.setFocused(undefined); + return; + } + // Safety net for hide paths that bypass hide_main_window and + // hideCurrentWindow: a blur with the window no longer visible + // means hidden, not just unfocused. Not sufficient alone — an + // earlier benign blur (e.g. shell.open) masks a later hide. The + // generation guard stops a stale visibility result from pausing a + // window that regained focus while the check was in flight. + const generation = focusGeneration; + void currentWindow.isVisible().then((visible) => { + if (visible || generation !== focusGeneration) return; + focusManager.setFocused(false); + }); + }), + ]; + + onCleanup(() => { + for (const unlisten of unlisteners) void unlisten.then((fn) => fn()); + }); +} + function createThemeListener(currentWindow: WebviewWindow) { const [appTheme, setAppTheme] = createSignal(); let disposed = false; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx index 132b271d45e..46c7d527b20 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx @@ -1,9 +1,9 @@ import { makePersisted } from "@solid-primitives/storage"; import { getVersion } from "@tauri-apps/api/app"; -import { getCurrentWindow } from "@tauri-apps/api/window"; import { createEffect, createResource } from "solid-js"; import { createStore } from "solid-js/store"; import Tooltip from "~/components/Tooltip"; +import { hideCurrentWindow } from "~/utils/hide-window"; import { commands } from "~/utils/tauri"; import { apiClient } from "~/utils/web-api"; import IconLucideBell from "~icons/lucide/bell"; @@ -36,7 +36,7 @@ const ChangelogButton = () => { const handleChangelogClick = () => { commands.showWindow({ Settings: { page: "changelog" } }); - getCurrentWindow().hide(); + hideCurrentWindow(); const version = currentVersion(); if (version) { setChangelogState({ diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 3beeea4d6c4..d9376fe3731 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -55,6 +55,7 @@ import { type MicrophoneWithDetails, } from "~/utils/devices"; import { clientEnv } from "~/utils/env"; +import { hideCurrentWindow } from "~/utils/hide-window"; import { importImageFromPicker, importVideoFromPicker, @@ -2024,7 +2025,7 @@ function Page() { if (pickerActive && !hasHidden && !recording) { setHasHiddenMainWindowForPicker(true); setShouldRevealMainWindowAfterPicker(!editorPicker); - void getCurrentWindow().hide(); + void hideCurrentWindow(); } else if (pickerActive && hasHidden) { setShouldRevealMainWindowAfterPicker(!editorPicker); } else if (recording) { @@ -2922,7 +2923,7 @@ function Page() { await shell.open(link); } - await getCurrentWindow().hide(); + await hideCurrentWindow(); }; const openScreenshot = async (screenshot: ScreenshotWithPath) => { @@ -3224,7 +3225,7 @@ function Page() { type="button" onClick={async () => { await commands.showWindow({ Settings: { page: "general" } }); - getCurrentWindow().hide(); + hideCurrentWindow(); }} class="flex items-center justify-center size-5 focus:outline-hidden" > @@ -3412,7 +3413,7 @@ function Page() { await commands.showWindow({ Settings: { page: "recordings" }, }); - getCurrentWindow().hide(); + hideCurrentWindow(); }} uploadProgress={uploadProgress} reuploadingPaths={reuploadingPaths()} @@ -3436,7 +3437,7 @@ function Page() { await commands.showWindow({ Settings: { page: "screenshots" }, }); - getCurrentWindow().hide(); + hideCurrentWindow(); }} /> ) : variant === "camera" ? ( diff --git a/apps/desktop/src/utils/hide-window.ts b/apps/desktop/src/utils/hide-window.ts new file mode 100644 index 00000000000..811344efe9b --- /dev/null +++ b/apps/desktop/src/utils/hide-window.ts @@ -0,0 +1,14 @@ +import { focusManager } from "@tanstack/solid-query"; +import { getCurrentWindow } from "@tauri-apps/api/window"; + +// Counterpart of the Rust-side hide_main_window for hides initiated by the +// window's own frontend. An earlier blur (e.g. shell.open stealing focus) +// leaves a later hide invisible to the focus bridge, and +// document.visibilityState never flips on Windows (tauri-apps/tauri#9524), +// so the polling pause must be explicit — after the hide succeeds, so a +// failed hide never pauses a still-visible window. +export async function hideCurrentWindow() { + const currentWindow = getCurrentWindow(); + await currentWindow.hide(); + if (currentWindow.label === "main") focusManager.setFocused(false); +} diff --git a/apps/desktop/src/utils/importMedia.ts b/apps/desktop/src/utils/importMedia.ts index 59401c42234..16127675c84 100644 --- a/apps/desktop/src/utils/importMedia.ts +++ b/apps/desktop/src/utils/importMedia.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; -import { getCurrentWindow } from "@tauri-apps/api/window"; import * as dialog from "@tauri-apps/plugin-dialog"; +import { hideCurrentWindow } from "~/utils/hide-window"; import { commands } from "~/utils/tauri"; const videoExtensions = [ @@ -32,7 +32,7 @@ const selectedPath = (result: string | string[] | null) => typeof result === "string" ? result : null; const maybeHideCurrentWindow = async (options?: ImportOptions) => { - if (options?.hideCurrentWindow) await getCurrentWindow().hide(); + if (options?.hideCurrentWindow) await hideCurrentWindow(); }; export const importVideoPath = async ( diff --git a/crates/camera-directshow/examples/cli.rs b/crates/camera-directshow/examples/cli.rs index 00161139dd8..0cc75850c30 100644 --- a/crates/camera-directshow/examples/cli.rs +++ b/crates/camera-directshow/examples/cli.rs @@ -42,7 +42,12 @@ mod windows { let device = selected.0; - let video_control = device.output_pin().cast::().ok(); + let output_pin = device + .output_pin() + .expect("failed to bind capture filter for selected device") + .clone(); + + let video_control = output_pin.cast::().ok(); let formats = device .media_types() @@ -65,7 +70,7 @@ mod windows { if let Some(video_control) = &video_control { let time_per_frame_list = video_control.time_per_frame_list( - device.output_pin(), + &output_pin, i as i32, SIZE { cx: width, diff --git a/crates/camera-directshow/src/lib.rs b/crates/camera-directshow/src/lib.rs index 9563e20b58b..d2166053d68 100644 --- a/crates/camera-directshow/src/lib.rs +++ b/crates/camera-directshow/src/lib.rs @@ -8,6 +8,7 @@ use std::{ ops::Deref, os::windows::ffi::OsStringExt, ptr::{self, null, null_mut}, + sync::OnceLock, time::Duration, }; use tracing::*; @@ -359,10 +360,20 @@ impl Iterator for VideoInputDeviceIterator { } } +/// Binding the capture filter opens the device through its KS driver, which +/// costs a thread and dozens of kernel handles that are not reclaimed on +/// release. Plain enumeration (name/id/model) must never pay that price, so +/// binding is deferred until formats or capture genuinely need it +/// (CapSoftware/Cap#2132). #[derive(Clone)] pub struct VideoInputDevice { moniker: IMoniker, prop_bag: IPropertyBag, + bound: OnceLock, +} + +#[derive(Clone)] +struct BoundFilter { filter: IBaseFilter, output_pin: IPin, stream_config: IAMStreamConfig, @@ -371,20 +382,31 @@ pub struct VideoInputDevice { impl VideoInputDevice { fn new(moniker: IMoniker) -> windows_core::Result { let prop_bag: IPropertyBag = unsafe { moniker.BindToStorage(None, None) }?; - let filter: IBaseFilter = unsafe { moniker.BindToObject(None, None) }?; + Ok(Self { + moniker, + prop_bag, + bound: OnceLock::new(), + }) + } + + fn bound(&self) -> windows_core::Result<&BoundFilter> { + if let Some(bound) = self.bound.get() { + return Ok(bound); + } + + let filter: IBaseFilter = unsafe { self.moniker.BindToObject(None, None) }?; let output_pin = filter .get_pin(PINDIR_OUTPUT, PIN_CATEGORY_CAPTURE, GUID::zeroed()) .ok_or(E_FAIL)?; - let stream_config = output_pin.cast::().ok().ok_or(E_FAIL)?; + let stream_config = output_pin.cast::()?; - Ok(Self { - moniker, - prop_bag, + let _ = self.bound.set(BoundFilter { filter, output_pin, stream_config, - }) + }); + self.bound.get().ok_or_else(|| E_FAIL.into()) } pub fn name(&self) -> Option { @@ -410,22 +432,16 @@ impl VideoInputDevice { } pub fn media_types(&self) -> Option> { - self.stream_config + self.bound() + .ok()? + .stream_config .media_types() .map(|inner| VideoMediaTypesIterator { inner }) .ok() } - pub fn filter(&self) -> &IBaseFilter { - &self.filter - } - - pub fn stream_config(&self) -> &IAMStreamConfig { - &self.stream_config - } - - pub fn output_pin(&self) -> &IPin { - &self.output_pin + pub fn output_pin(&self) -> windows_core::Result<&IPin> { + Ok(&self.bound()?.output_pin) } pub fn start_capturing( @@ -433,8 +449,14 @@ impl VideoInputDevice { format: &AMMediaType, callback: SinkCallback, ) -> Result { + let bound = self + .bound() + .map_err(StartCapturingError::BindDevice)? + .clone(); + unsafe { - self.stream_config + bound + .stream_config .SetFormat(&**format) .map_err(StartCapturingError::Other)?; @@ -460,7 +482,7 @@ impl VideoInputDevice { .SetFiltergraph(&graph_builder) .map_err(StartCapturingError::ConfigureGraph)?; graph_builder - .AddFilter(&self.filter, None) + .AddFilter(&bound.filter, None) .map_err(StartCapturingError::ConfigureGraph)?; let sink_filter: IBaseFilter = sink_filter @@ -476,14 +498,14 @@ impl VideoInputDevice { .FindInterface( Some(&PIN_CATEGORY_CAPTURE), Some(&MEDIATYPE_Video), - &self.filter, + &bound.filter, &IAMStreamConfig::IID, &mut stream_config, ) .map_err(StartCapturingError::ConfigureGraph)?; graph_builder - .Connect(&self.output_pin, &input_sink_pin) + .Connect(&bound.output_pin, &input_sink_pin) .map_err(StartCapturingError::ConfigureGraph)?; media_control.Run().map_err(StartCapturingError::Run)?; @@ -491,7 +513,7 @@ impl VideoInputDevice { Ok(CaptureHandle { media_control, graph_builder, - output_capture_pin: self.output_pin, + output_capture_pin: bound.output_pin, input_sink_pin, }) } @@ -521,6 +543,8 @@ impl CaptureHandle { #[derive(thiserror::Error, Debug)] pub enum StartCapturingError { + #[error("BindDevice: {0}")] + BindDevice(windows_core::Error), #[error("No input pin")] NoInputPin, #[error("CreateGraph: {0}")] diff --git a/crates/camera-mediafoundation/src/lib.rs b/crates/camera-mediafoundation/src/lib.rs index 5e7610ac9aa..246b1c311e9 100644 --- a/crates/camera-mediafoundation/src/lib.rs +++ b/crates/camera-mediafoundation/src/lib.rs @@ -10,15 +10,17 @@ use std::{ ops::{Deref, DerefMut}, os::windows::ffi::OsStringExt, slice::from_raw_parts, - sync::mpsc::{Receiver, Sender, channel}, + sync::{ + OnceLock, + mpsc::{Receiver, Sender, channel}, + }, time::Duration, }; -use tracing::error; use windows::Win32::{ Foundation::{S_FALSE, *}, Media::MediaFoundation::*, System::{ - Com::{CLSCTX_INPROC_SERVER, CoCreateInstance, CoInitialize}, + Com::{CLSCTX_INPROC_SERVER, CoCreateInstance, CoInitialize, CoTaskMemFree}, Performance::QueryPerformanceCounter, }, }; @@ -73,6 +75,20 @@ impl DeviceSourcesIterator { } } +// MFEnumDeviceSources hands over one IMFActivate reference per device plus the +// CoTaskMemAlloc'd array itself; without this Drop every enumeration leaked +// both for the life of the process (CapSoftware/Cap#2132). +impl Drop for DeviceSourcesIterator { + fn drop(&mut self) { + unsafe { + for index in 0..self.count { + (*self.devices.add(index as usize)).take(); + } + CoTaskMemFree(Some(self.devices as *const _)); + } + } +} + impl Iterator for DeviceSourcesIterator { type Item = Device; @@ -93,30 +109,29 @@ impl Iterator for DeviceSourcesIterator { continue; }; - let media_source = match unsafe { device.ActivateObject::() } { - Ok(v) => v, - Err(e) => { - error!("Failed to activate IMFMediaSource: {}", e); - return None; - } - }; - return Some(Device { - media_source, activate: device.clone(), + media_source: OnceLock::new(), }); } } } +/// Activating the media source opens the physical device through its driver, +/// which costs real OS resources that are only reclaimed by `Shutdown`. Plain +/// enumeration (name/id/model) must never pay that price, so activation is +/// deferred until formats or capture genuinely need it +/// (CapSoftware/Cap#2132). #[derive(Clone)] pub struct Device { activate: IMFActivate, - pub media_source: IMFMediaSource, + media_source: OnceLock, } #[derive(thiserror::Error, Debug)] pub enum StartCapturingError { + #[error("ActivateDevice: {0}")] + ActivateDevice(windows_core::Error), #[error("CreateEngine: {0}")] CreateEngine(windows_core::Error), #[error("ConfigureEngine: {0}")] @@ -132,27 +147,49 @@ pub enum StartCapturingError { } impl Device { + fn media_source(&self) -> windows_core::Result<&IMFMediaSource> { + if let Some(media_source) = self.media_source.get() { + return Ok(media_source); + } + + let media_source = unsafe { self.activate.ActivateObject::() }?; + let _ = self.media_source.set(media_source); + self.media_source.get().ok_or_else(|| E_FAIL.into()) + } + + /// Retires this instance's Media Foundation path for good, releasing the + /// device at the driver so another backend can open it. Any cached source + /// stays shut down afterwards; only call this when abandoning MF for this + /// device. + pub fn shutdown(&self) { + if let Some(media_source) = self.media_source.get() { + let _ = unsafe { media_source.Shutdown() }; + } + let _ = unsafe { self.activate.ShutdownObject() }; + } + pub fn name(&self) -> windows_core::Result { - let mut raw = PWSTR(&mut 0); - let mut length = 0; + unsafe { self.read_allocated_string(&MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME) } + } + + pub fn id(&self) -> windows_core::Result { unsafe { - self.activate - .GetAllocatedString(&MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME, &mut raw, &mut length) - .map(|_| OsString::from_wide(from_raw_parts(raw.0, length as usize))) + self.read_allocated_string(&MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_SYMBOLIC_LINK) } } - pub fn id(&self) -> windows_core::Result { - let mut raw = PWSTR(&mut 0); + unsafe fn read_allocated_string( + &self, + key: &windows_core::GUID, + ) -> windows_core::Result { + let mut raw = PWSTR::null(); let mut length = 0; unsafe { self.activate - .GetAllocatedString( - &MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_SYMBOLIC_LINK, - &mut raw, - &mut length, - ) - .map(|_| OsString::from_wide(from_raw_parts(raw.0, length as usize))) + .GetAllocatedString(key, &mut raw, &mut length)?; + let value = OsString::from_wide(from_raw_parts(raw.0, length as usize)); + CoTaskMemFree(Some(raw.0 as *const _)); + Ok(value) } } @@ -166,6 +203,7 @@ impl Device { // Creates and disposes an IMFSourceReader internally, // so this device must be shut down manually after calling this function. pub fn formats(&self) -> windows_core::Result> { + let media_source = self.media_source()?; let mut stream_index = 0; let reader = unsafe { @@ -175,7 +213,7 @@ impl Device { attributes.ok_or_else(|| windows_core::Error::from_hresult(S_FALSE))?; // Media source shuts down on drop if this isn't specified attributes.SetUINT32(&MF_SOURCE_READER_DISCONNECT_MEDIASOURCE_ON_SHUTDOWN, 1)?; - MFCreateSourceReaderFromMediaSource(&self.media_source, &attributes) + MFCreateSourceReaderFromMediaSource(media_source, &attributes) .map(|inner| SourceReader { inner }) }?; @@ -197,6 +235,10 @@ impl Device { requested_format: &IMFMediaType, callback: Box, ) -> Result { + let media_source = self + .media_source() + .map_err(StartCapturingError::ActivateDevice)?; + unsafe { let capture_engine_factory: IMFCaptureEngineClassFactory = CoCreateInstance( &CLSID_MFCaptureEngineClassFactory, @@ -232,7 +274,7 @@ impl Device { &video_callback.to_interface::(), &attributes, None, - &self.media_source, + media_source, ) .map_err(StartCapturingError::InitializeEngine)?; @@ -380,20 +422,6 @@ impl CaptureHandle { } } -impl Deref for Device { - type Target = IMFMediaSource; - - fn deref(&self) -> &Self::Target { - &self.media_source - } -} - -impl DerefMut for Device { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.media_source - } -} - impl Display for Device { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( diff --git a/crates/camera-windows/examples/enumeration_leak.rs b/crates/camera-windows/examples/enumeration_leak.rs new file mode 100644 index 00000000000..0822ffbd7e6 --- /dev/null +++ b/crates/camera-windows/examples/enumeration_leak.rs @@ -0,0 +1,54 @@ +//! Repro for CapSoftware/Cap#2132: repeatedly enumerate cameras while +//! watching threads, handles and private bytes of this process from outside +//! (Process Explorer, or `Get-Process -Id ` in a loop). +//! +//! Usage: enumeration_leak.exe [mf|ds|both] [iterations] [sleep_ms] +//! +//! `mf` and `ds` isolate the Media Foundation and DirectShow halves of +//! `get_devices()`; whichever mode grows is the leaking half. The printed +//! per-enumeration wall time also rises as leaked threads and handles +//! accumulate. + +fn main() { + let mode = std::env::args().nth(1).unwrap_or_else(|| "both".into()); + let iterations: usize = std::env::args() + .nth(2) + .and_then(|v| v.parse().ok()) + .unwrap_or(60); + let sleep_ms: u64 = std::env::args() + .nth(3) + .and_then(|v| v.parse().ok()) + .unwrap_or(1000); + + println!( + "pid {} mode={mode} iterations={iterations} sleep_ms={sleep_ms}", + std::process::id() + ); + + let _ = cap_camera_directshow::initialize_directshow(); + let _ = cap_camera_mediafoundation::initialize_mediafoundation(); + + for i in 1..=iterations { + let start = std::time::Instant::now(); + + let devices = match mode.as_str() { + "mf" => cap_camera_mediafoundation::DeviceSourcesIterator::new() + .map(|devices| devices.count()) + .unwrap_or(0), + "ds" => cap_camera_directshow::VideoInputDeviceIterator::new() + .map(|devices| devices.count()) + .unwrap_or(0), + _ => cap_camera_windows::get_devices() + .map(|devices| devices.len()) + .unwrap_or(0), + }; + + println!( + "{i}: {devices} device(s) in {}ms", + start.elapsed().as_millis() + ); + std::thread::sleep(std::time::Duration::from_millis(sleep_ms)); + } + + println!("done"); +} diff --git a/crates/camera-windows/src/lib.rs b/crates/camera-windows/src/lib.rs index 17211b68efc..2fd12c5779d 100644 --- a/crates/camera-windows/src/lib.rs +++ b/crates/camera-windows/src/lib.rs @@ -189,6 +189,8 @@ pub enum StartCapturingError { DirectShow(#[from] cap_camera_directshow::StartCapturingError), #[error("Format/{0}")] Format(#[from] VideoFormatError), + #[error("format doesn't match any backend available for this device")] + FormatMismatch, } impl VideoDeviceInfo { @@ -321,7 +323,7 @@ impl VideoDeviceInfo { ) -> Result { let res = match (self.inner, &format) { ( - VideoDeviceInfoInner::MediaFoundation { device }, + VideoDeviceInfoInner::MediaFoundation { device, .. }, VideoFormatInner::MediaFoundation(mf_format), ) => { let format = VideoFormat::new_mf(mf_format.clone())?; @@ -351,7 +353,14 @@ impl VideoDeviceInfo { CaptureHandle::MediaFoundation(handle) } - (VideoDeviceInfoInner::DirectShow(device), VideoFormatInner::DirectShow(format)) => { + ( + VideoDeviceInfoInner::DirectShow(device) + | VideoDeviceInfoInner::MediaFoundation { + dshow_fallback: Some(device), + .. + }, + VideoFormatInner::DirectShow(format), + ) => { let handle = device.start_capturing( format, Box::new(move |data| { @@ -384,7 +393,7 @@ impl VideoDeviceInfo { CaptureHandle::DirectShow(handle) } - _ => todo!(), + _ => return Err(StartCapturingError::FormatMismatch), }; Ok(res) @@ -392,20 +401,32 @@ impl VideoDeviceInfo { pub fn formats(&self) -> Vec { match &self.inner { - VideoDeviceInfoInner::MediaFoundation { device } => device - .formats() - .map(|formats| { - formats - .filter_map(|t| VideoFormat::new_mf(t).ok()) - .collect::>() - }) - .unwrap_or_default(), - VideoDeviceInfoInner::DirectShow(device) => device - .media_types() - .into_iter() - .flatten() - .filter_map(|media_type| VideoFormat::new_ds(media_type).ok()) - .collect::>(), + VideoDeviceInfoInner::MediaFoundation { + device, + dshow_fallback, + } => { + let formats = device + .formats() + .map(|formats| { + formats + .filter_map(|t| VideoFormat::new_mf(t).ok()) + .collect::>() + }) + .unwrap_or_default(); + + if !formats.is_empty() { + return formats; + } + + // MF yielded nothing for this device, so release it at the + // driver: repeated format requests must not accumulate + // half-open sources, and exclusive drivers reject + // DirectShow's bind while the failed source holds the device. + device.shutdown(); + + dshow_fallback.as_ref().map(ds_formats).unwrap_or_default() + } + VideoDeviceInfoInner::DirectShow(device) => ds_formats(device), } } } @@ -507,10 +528,26 @@ fn directshow_frame_is_bottom_up(pixel_format: PixelFormat, bi_height: i32) -> b bi_height > 0 && pixel_format.is_traditionally_bottom_up() } +fn ds_formats(device: &cap_camera_directshow::VideoInputDevice) -> Vec { + device + .media_types() + .into_iter() + .flatten() + .filter_map(|media_type| VideoFormat::new_ds(media_type).ok()) + .collect() +} + #[derive(Clone)] enum VideoDeviceInfoInner { MediaFoundation { device: cap_camera_mediafoundation::Device, + // Some devices register with Media Foundation but only actually work + // through DirectShow (capture cards, some virtual cameras). The DS + // twin is kept so formats/capture can fall back to it when the MF + // device fails to activate or reports no formats — probing MF during + // enumeration would open every device on every poll + // (CapSoftware/Cap#2132). + dshow_fallback: Option, }, DirectShow(cap_camera_directshow::VideoInputDevice), } @@ -549,7 +586,10 @@ pub fn get_devices() -> Result, GetDevicesError> { id, model_id, category, - inner: VideoDeviceInfoInner::MediaFoundation { device }, + inner: VideoDeviceInfoInner::MediaFoundation { + device, + dshow_fallback: None, + }, }) }) .filter_map(|result| match result { @@ -590,16 +630,27 @@ pub fn get_devices() -> Result, GetDevicesError> { for dshow_device in dshow_devices { let name_and_model = dshow_device.name_and_model(); - let mf_device = devices - .iter() - .enumerate() - .find(|(_, device)| device.is_mf() && device.name_and_model() == name_and_model); - - match mf_device { - Some((i, mf_device)) => { - if mf_device.formats().is_empty() { - devices.push(mf_device.clone()); - devices.swap_remove(i); + // Identical devices produce identical names; pair each DS device with + // the first MF entry that doesn't have a fallback yet so twins map + // one-to-one instead of piling onto the first match. + let mf_twin = devices.iter().position(|device| { + matches!( + &device.inner, + VideoDeviceInfoInner::MediaFoundation { + dshow_fallback: None, + .. + } + ) && device.name_and_model() == name_and_model + }); + + match mf_twin { + Some(index) => { + if let ( + VideoDeviceInfoInner::MediaFoundation { dshow_fallback, .. }, + VideoDeviceInfoInner::DirectShow(dshow), + ) = (&mut devices[index].inner, dshow_device.inner) + { + *dshow_fallback = Some(dshow); } } None => devices.push(dshow_device),