Skip to content

Commit 716d2e8

Browse files
authored
fix(windows): stop camera enumeration leaking device resources and pause idle polling in the tray (#2143)
* fix: stop Media Foundation camera enumeration leaking device resources * fix: pause device polling while Cap idles hidden in the tray * fix: stop DirectShow camera enumeration instantiating capture filters * fix: fall back to DirectShow when a Media Foundation device is unusable * fix: cover remaining main-window hide paths and restore native visibility detection * fix: release unusable MF sources and harden DirectShow fallback pairing * fix: pause queries when the main window hides itself from the frontend * fix: shut down unusable MF-only devices before returning empty formats * fix: pause queries explicitly on frontend main-window hides * fix: harden main-window hide coverage and pause ordering
1 parent 146f01a commit 716d2e8

13 files changed

Lines changed: 362 additions & 125 deletions

File tree

apps/desktop/src-tauri/src/general_settings.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@ impl MainWindowRecordingStartBehaviour {
9292
#[cfg(windows)]
9393
return window.minimize();
9494
#[cfg(not(windows))]
95-
window.hide()
95+
{
96+
crate::hide_main_window(window.app_handle());
97+
Ok(())
98+
}
9699
}
97100
Self::Minimise => window.minimize(),
98101
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1752,6 +1752,12 @@ async fn get_devices_snapshot() -> DevicesUpdated {
17521752
}
17531753
}
17541754

1755+
fn any_webview_window_visible(app: &AppHandle) -> bool {
1756+
app.webview_windows()
1757+
.values()
1758+
.any(|window| window.is_visible().unwrap_or(false))
1759+
}
1760+
17551761
fn spawn_devices_snapshot_emitter(app_handle: AppHandle) {
17561762
tokio::spawn(async move {
17571763
let mut last_perm_tuple: (u8, u8, u8, u8) = (255, 255, 255, 255);
@@ -1768,6 +1774,14 @@ fn spawn_devices_snapshot_emitter(app_handle: AppHandle) {
17681774
continue;
17691775
}
17701776

1777+
// Device snapshots only feed UI pickers via DevicesUpdated, so
1778+
// polling while every window sits hidden in the tray probes the
1779+
// OS device stack and burns CPU for nobody (#2132).
1780+
if !any_webview_window_visible(&app_handle) {
1781+
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1782+
continue;
1783+
}
1784+
17711785
let permissions = permissions::do_permissions_check(false);
17721786
let Some((cameras, microphones)) = collect_device_inventory(
17731787
|| app_is_exiting(&app_handle),
@@ -4741,9 +4755,7 @@ pub async fn open_target_picker(
47414755
) {
47424756
use tauri::Manager;
47434757

4744-
if let Some(window) = CapWindowId::Main.get(app) {
4745-
window.hide().ok();
4746-
}
4758+
hide_main_window(app);
47474759

47484760
let state = app.state::<target_select_overlay::WindowFocusManager>();
47494761
let display_id = None;
@@ -5665,7 +5677,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
56655677
}
56665678
CapWindowId::Main => {
56675679
api.prevent_close();
5668-
let _ = window.hide();
5680+
hide_main_window(app);
56695681

56705682
#[cfg(target_os = "macos")]
56715683
crate::permissions::schedule_macos_dock_visibility_sync(app);
@@ -6819,9 +6831,15 @@ fn show_import_error_dialog(app: &AppHandle, message: String) {
68196831
.show(|_| {});
68206832
}
68216833

6822-
fn hide_main_window(app: &AppHandle) {
6823-
if let Some(main_window) = CapWindowId::Main.get(app) {
6824-
let _ = main_window.hide();
6834+
// Hidden webviews on Windows never see document.visibilityState change
6835+
// (tauri-apps/tauri#9524), so the frontend cannot detect hide-to-tray on its
6836+
// own; this event lets it pause polling, and only fires when the hide
6837+
// actually happened so a failed hide never pauses a visible window (#2132).
6838+
pub(crate) fn hide_main_window(app: &AppHandle) {
6839+
if let Some(main_window) = CapWindowId::Main.get(app)
6840+
&& main_window.hide().is_ok()
6841+
{
6842+
let _ = main_window.emit_to(CapWindowId::Main.label(), "main-window-hidden", ());
68256843
}
68266844
}
68276845

@@ -6901,9 +6919,7 @@ fn open_project_from_path(path: &Path, app: AppHandle) -> Result<(), String> {
69016919
let _ = app
69026920
.opener()
69036921
.open_path(mp4_path.to_str().unwrap_or_default(), None::<String>);
6904-
if let Some(main_window) = CapWindowId::Main.get(&app) {
6905-
main_window.hide().ok();
6906-
}
6922+
hide_main_window(&app);
69076923
}
69086924
}
69096925
}

apps/desktop/src-tauri/src/windows.rs

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ fn hide_recording_windows(app: &AppHandle, restore_target_select_overlays: bool)
163163
focus_manager.remember_overlay_for_restore(label);
164164
}
165165
hide_overlay(&window);
166+
} else if matches!(id, CapWindowId::Main) {
167+
crate::hide_main_window(app);
166168
} else {
167169
let _ = window.hide();
168170
}
@@ -1515,7 +1517,7 @@ impl ShowCapWindow {
15151517
init_target_mode: Some(target_mode),
15161518
} = self
15171519
{
1518-
window.hide().ok();
1520+
crate::hide_main_window(app);
15191521
emit_app_event(
15201522
app,
15211523
RequestSetTargetMode {
@@ -2044,9 +2046,7 @@ impl ShowCapWindow {
20442046
window
20452047
}
20462048
Self::Upgrade => {
2047-
if let Some(main) = CapWindowId::Main.get(app) {
2048-
let _ = main.hide();
2049-
}
2049+
crate::hide_main_window(app);
20502050

20512051
let window = self
20522052
.window_builder(app, "/upgrade")
@@ -2080,9 +2080,7 @@ impl ShowCapWindow {
20802080
window
20812081
}
20822082
Self::ModeSelect => {
2083-
if let Some(main) = CapWindowId::Main.get(app) {
2084-
let _ = main.hide();
2085-
}
2083+
crate::hide_main_window(app);
20862084

20872085
let window = self
20882086
.window_builder(app, "/mode-select")
@@ -2116,9 +2114,7 @@ impl ShowCapWindow {
21162114
window
21172115
}
21182116
Self::Onboarding => {
2119-
if let Some(main) = CapWindowId::Main.get(app) {
2120-
let _ = main.hide();
2121-
}
2117+
crate::hide_main_window(app);
21222118

21232119
let width = (cursor_monitor.width * 0.58).clamp(860.0, 1080.0);
21242120
let height = (width * 0.72).clamp(690.0, 780.0);

apps/desktop/src/app.tsx

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { Route, Router, useCurrentMatches } from "@solidjs/router";
2-
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
2+
import {
3+
focusManager,
4+
QueryClient,
5+
QueryClientProvider,
6+
} from "@tanstack/solid-query";
37
import {
48
getCurrentWebviewWindow,
59
type WebviewWindow,
@@ -124,6 +128,7 @@ export default function App() {
124128
function Inner() {
125129
const currentWindow = getCurrentWebviewWindow();
126130
createThemeListener(currentWindow);
131+
createHiddenWindowQueryPause(currentWindow);
127132

128133
onMount(() => {
129134
initAnonymousUser();
@@ -288,6 +293,46 @@ function prewarmFontCaches() {
288293
else setTimeout(warm, 250);
289294
}
290295

296+
// Hidden Tauri windows never flip document.visibilityState on Windows
297+
// (tauri-apps/tauri#9524), so TanStack keeps every refetchInterval firing
298+
// while the app idles in the tray (#2132). Pause queries when the backend
299+
// hides the window; on focus, hand control back to TanStack's own
300+
// visibilitychange detection (setFocused(undefined)) so platforms where it
301+
// works, like macOS minimize, keep pausing natively.
302+
function createHiddenWindowQueryPause(currentWindow: WebviewWindow) {
303+
if (currentWindow.label !== "main") return;
304+
305+
let focusGeneration = 0;
306+
307+
const unlisteners = [
308+
currentWindow.listen("main-window-hidden", () => {
309+
focusManager.setFocused(false);
310+
}),
311+
currentWindow.onFocusChanged((event) => {
312+
focusGeneration += 1;
313+
if (event.payload) {
314+
focusManager.setFocused(undefined);
315+
return;
316+
}
317+
// Safety net for hide paths that bypass hide_main_window and
318+
// hideCurrentWindow: a blur with the window no longer visible
319+
// means hidden, not just unfocused. Not sufficient alone — an
320+
// earlier benign blur (e.g. shell.open) masks a later hide. The
321+
// generation guard stops a stale visibility result from pausing a
322+
// window that regained focus while the check was in flight.
323+
const generation = focusGeneration;
324+
void currentWindow.isVisible().then((visible) => {
325+
if (visible || generation !== focusGeneration) return;
326+
focusManager.setFocused(false);
327+
});
328+
}),
329+
];
330+
331+
onCleanup(() => {
332+
for (const unlisten of unlisteners) void unlisten.then((fn) => fn());
333+
});
334+
}
335+
291336
function createThemeListener(currentWindow: WebviewWindow) {
292337
const [appTheme, setAppTheme] = createSignal<AppTheme | null | undefined>();
293338
let disposed = false;

apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { makePersisted } from "@solid-primitives/storage";
22
import { getVersion } from "@tauri-apps/api/app";
3-
import { getCurrentWindow } from "@tauri-apps/api/window";
43
import { createEffect, createResource } from "solid-js";
54
import { createStore } from "solid-js/store";
65
import Tooltip from "~/components/Tooltip";
6+
import { hideCurrentWindow } from "~/utils/hide-window";
77
import { commands } from "~/utils/tauri";
88
import { apiClient } from "~/utils/web-api";
99
import IconLucideBell from "~icons/lucide/bell";
@@ -36,7 +36,7 @@ const ChangelogButton = () => {
3636

3737
const handleChangelogClick = () => {
3838
commands.showWindow({ Settings: { page: "changelog" } });
39-
getCurrentWindow().hide();
39+
hideCurrentWindow();
4040
const version = currentVersion();
4141
if (version) {
4242
setChangelogState({

apps/desktop/src/routes/(window-chrome)/new-main/index.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
type MicrophoneWithDetails,
5656
} from "~/utils/devices";
5757
import { clientEnv } from "~/utils/env";
58+
import { hideCurrentWindow } from "~/utils/hide-window";
5859
import {
5960
importImageFromPicker,
6061
importVideoFromPicker,
@@ -2024,7 +2025,7 @@ function Page() {
20242025
if (pickerActive && !hasHidden && !recording) {
20252026
setHasHiddenMainWindowForPicker(true);
20262027
setShouldRevealMainWindowAfterPicker(!editorPicker);
2027-
void getCurrentWindow().hide();
2028+
void hideCurrentWindow();
20282029
} else if (pickerActive && hasHidden) {
20292030
setShouldRevealMainWindowAfterPicker(!editorPicker);
20302031
} else if (recording) {
@@ -2922,7 +2923,7 @@ function Page() {
29222923
await shell.open(link);
29232924
}
29242925

2925-
await getCurrentWindow().hide();
2926+
await hideCurrentWindow();
29262927
};
29272928

29282929
const openScreenshot = async (screenshot: ScreenshotWithPath) => {
@@ -3224,7 +3225,7 @@ function Page() {
32243225
type="button"
32253226
onClick={async () => {
32263227
await commands.showWindow({ Settings: { page: "general" } });
3227-
getCurrentWindow().hide();
3228+
hideCurrentWindow();
32283229
}}
32293230
class="flex items-center justify-center size-5 focus:outline-hidden"
32303231
>
@@ -3412,7 +3413,7 @@ function Page() {
34123413
await commands.showWindow({
34133414
Settings: { page: "recordings" },
34143415
});
3415-
getCurrentWindow().hide();
3416+
hideCurrentWindow();
34163417
}}
34173418
uploadProgress={uploadProgress}
34183419
reuploadingPaths={reuploadingPaths()}
@@ -3436,7 +3437,7 @@ function Page() {
34363437
await commands.showWindow({
34373438
Settings: { page: "screenshots" },
34383439
});
3439-
getCurrentWindow().hide();
3440+
hideCurrentWindow();
34403441
}}
34413442
/>
34423443
) : variant === "camera" ? (
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { focusManager } from "@tanstack/solid-query";
2+
import { getCurrentWindow } from "@tauri-apps/api/window";
3+
4+
// Counterpart of the Rust-side hide_main_window for hides initiated by the
5+
// window's own frontend. An earlier blur (e.g. shell.open stealing focus)
6+
// leaves a later hide invisible to the focus bridge, and
7+
// document.visibilityState never flips on Windows (tauri-apps/tauri#9524),
8+
// so the polling pause must be explicit — after the hide succeeds, so a
9+
// failed hide never pauses a still-visible window.
10+
export async function hideCurrentWindow() {
11+
const currentWindow = getCurrentWindow();
12+
await currentWindow.hide();
13+
if (currentWindow.label === "main") focusManager.setFocused(false);
14+
}

apps/desktop/src/utils/importMedia.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { invoke } from "@tauri-apps/api/core";
2-
import { getCurrentWindow } from "@tauri-apps/api/window";
32
import * as dialog from "@tauri-apps/plugin-dialog";
3+
import { hideCurrentWindow } from "~/utils/hide-window";
44
import { commands } from "~/utils/tauri";
55

66
const videoExtensions = [
@@ -32,7 +32,7 @@ const selectedPath = (result: string | string[] | null) =>
3232
typeof result === "string" ? result : null;
3333

3434
const maybeHideCurrentWindow = async (options?: ImportOptions) => {
35-
if (options?.hideCurrentWindow) await getCurrentWindow().hide();
35+
if (options?.hideCurrentWindow) await hideCurrentWindow();
3636
};
3737

3838
export const importVideoPath = async (

crates/camera-directshow/examples/cli.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ mod windows {
4242

4343
let device = selected.0;
4444

45-
let video_control = device.output_pin().cast::<IAMVideoControl>().ok();
45+
let output_pin = device
46+
.output_pin()
47+
.expect("failed to bind capture filter for selected device")
48+
.clone();
49+
50+
let video_control = output_pin.cast::<IAMVideoControl>().ok();
4651

4752
let formats = device
4853
.media_types()
@@ -65,7 +70,7 @@ mod windows {
6570

6671
if let Some(video_control) = &video_control {
6772
let time_per_frame_list = video_control.time_per_frame_list(
68-
device.output_pin(),
73+
&output_pin,
6974
i as i32,
7075
SIZE {
7176
cx: width,

0 commit comments

Comments
 (0)