Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -1766,6 +1772,15 @@ fn spawn_devices_snapshot_emitter(app_handle: AppHandle) {
continue;
}

// Device snapshots only feed UI pickers via DevicesUpdated, and on
// Windows every enumeration instantiates each capture device, so
// polling while all windows sit hidden in the tray 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),
Expand Down Expand Up @@ -4739,9 +4754,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::<target_select_overlay::WindowFocusManager>();
let display_id = None;
Expand Down Expand Up @@ -5644,7 +5657,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);
Expand Down Expand Up @@ -6798,9 +6811,13 @@ fn show_import_error_dialog(app: &AppHandle, message: String) {
.show(|_| {});
}

// 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 (#2132).
fn hide_main_window(app: &AppHandle) {
if let Some(main_window) = CapWindowId::Main.get(app) {
let _ = main_window.hide();
let _ = main_window.emit_to(CapWindowId::Main.label(), "main-window-hidden", ());
}
}

Expand Down Expand Up @@ -6880,9 +6897,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::<String>);
if let Some(main_window) = CapWindowId::Main.get(&app) {
main_window.hide().ok();
}
hide_main_window(&app);
}
}
}
Expand Down
28 changes: 27 additions & 1 deletion apps/desktop/src/app.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -124,6 +128,7 @@ export default function App() {
function Inner() {
const currentWindow = getCurrentWebviewWindow();
createThemeListener(currentWindow);
createHiddenWindowQueryPause(currentWindow);

onMount(() => {
initAnonymousUser();
Expand Down Expand Up @@ -288,6 +293,27 @@ 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 from the window's
// real hide/focus signals instead.
function createHiddenWindowQueryPause(currentWindow: WebviewWindow) {
if (currentWindow.label !== "main") return;

const unlisteners = [
currentWindow.listen("main-window-hidden", () => {
focusManager.setFocused(false);
}),
currentWindow.onFocusChanged((event) => {
if (event.payload) focusManager.setFocused(true);
}),
];

onCleanup(() => {
for (const unlisten of unlisteners) void unlisten.then((fn) => fn());
});
}

function createThemeListener(currentWindow: WebviewWindow) {
const [appTheme, setAppTheme] = createSignal<AppTheme | null | undefined>();
let disposed = false;
Expand Down
101 changes: 59 additions & 42 deletions crates/camera-mediafoundation/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
Expand Down Expand Up @@ -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;

Expand All @@ -93,30 +109,29 @@ impl Iterator for DeviceSourcesIterator {
continue;
};

let media_source = match unsafe { device.ActivateObject::<IMFMediaSource>() } {
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<IMFMediaSource>,
}

#[derive(thiserror::Error, Debug)]
pub enum StartCapturingError {
#[error("ActivateDevice: {0}")]
ActivateDevice(windows_core::Error),
#[error("CreateEngine: {0}")]
CreateEngine(windows_core::Error),
#[error("ConfigureEngine: {0}")]
Expand All @@ -132,27 +147,38 @@ 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::<IMFMediaSource>() }?;
let _ = self.media_source.set(media_source);
self.media_source.get().ok_or_else(|| E_FAIL.into())
}

pub fn name(&self) -> windows_core::Result<OsString> {
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<OsString> {
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<OsString> {
let mut raw = PWSTR(&mut 0);
unsafe fn read_allocated_string(
&self,
key: &windows_core::GUID,
) -> windows_core::Result<OsString> {
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)
}
}

Expand All @@ -166,6 +192,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<impl Iterator<Item = IMFMediaType>> {
let media_source = self.media_source()?;
let mut stream_index = 0;

let reader = unsafe {
Expand All @@ -175,7 +202,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 })
}?;

Expand All @@ -197,6 +224,10 @@ impl Device {
requested_format: &IMFMediaType,
callback: Box<dyn FnMut(CallbackData) + 'static>,
) -> Result<CaptureHandle, StartCapturingError> {
let media_source = self
.media_source()
.map_err(StartCapturingError::ActivateDevice)?;

unsafe {
let capture_engine_factory: IMFCaptureEngineClassFactory = CoCreateInstance(
&CLSID_MFCaptureEngineClassFactory,
Expand Down Expand Up @@ -232,7 +263,7 @@ impl Device {
&video_callback.to_interface::<IMFCaptureEngineOnEventCallback>(),
&attributes,
None,
&self.media_source,
media_source,
)
.map_err(StartCapturingError::InitializeEngine)?;

Expand Down Expand Up @@ -380,20 +411,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!(
Expand Down
20 changes: 8 additions & 12 deletions crates/camera-windows/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -587,22 +587,18 @@ pub fn get_devices() -> Result<Vec<VideoDeviceInfo>, GetDevicesError> {

let mut devices = mf_devices;

// The previous MF-formats probe here was a no-op (it re-inserted the same
// MF entry in both branches) that opened every paired device on every
// enumeration; deduplication only needs names (CapSoftware/Cap#2132).
for dshow_device in dshow_devices {
let name_and_model = dshow_device.name_and_model();

let mf_device = devices
let already_listed = 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);
}
}
None => devices.push(dshow_device),
.any(|device| device.is_mf() && device.name_and_model() == name_and_model);

if !already_listed {
devices.push(dshow_device);
}
}

Expand Down
Loading