From 778d80f5e6724a18d22c564e3aa60771a636d768 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:28:59 +0100 Subject: [PATCH 1/9] feat: Basic window excluding UI --- .claude/settings.local.json | 21 +- .../desktop/src-tauri/src/general_settings.rs | 45 ++ apps/desktop/src-tauri/src/lib.rs | 5 +- apps/desktop/src-tauri/src/recording.rs | 15 +- apps/desktop/src-tauri/src/thumbnails/mod.rs | 2 + apps/desktop/src-tauri/src/windows.rs | 67 ++- .../(window-chrome)/settings/general.tsx | 384 +++++++++++++++++- apps/desktop/src/utils/tauri.ts | 13 +- crates/recording/src/capture_pipeline.rs | 4 +- crates/recording/src/instant_recording.rs | 13 +- crates/recording/src/lib.rs | 6 +- .../src/sources/screen_capture/macos.rs | 44 +- .../src/sources/screen_capture/mod.rs | 76 +++- crates/recording/src/studio_recording.rs | 20 +- crates/scap-targets/src/lib.rs | 4 + crates/scap-targets/src/platform/macos.rs | 36 +- 16 files changed, 695 insertions(+), 60 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 083ec9bd45..5503f40598 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,11 +1,12 @@ { - "permissions": { - "allow": [ - "Bash(pnpm typecheck:*)", - "Bash(pnpm lint:*)", - "Bash(pnpm build:*)" - ], - "deny": [], - "ask": [] - } -} + "permissions": { + "allow": [ + "Bash(pnpm typecheck:*)", + "Bash(pnpm lint:*)", + "Bash(pnpm build:*)", + "Bash(cargo check:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 771ae56a81..658dbc6545 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -1,3 +1,4 @@ +use cap_recording::sources::screen_capture::WindowExclusion; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; @@ -39,6 +40,30 @@ impl MainWindowRecordingStartBehaviour { } } +const DEFAULT_EXCLUDED_WINDOW_TITLES: &[&str] = &[ + "Cap", + "Cap Setup", + "Cap Settings", + "Cap Editor", + "Cap Mode Selection", + "Cap Camera", + "Cap Recordings Overlay", + "Cap In Progress Recording", + "Cap Window Capture Occluder", + "Cap Capture Area", +]; + +pub fn default_excluded_windows() -> Vec { + DEFAULT_EXCLUDED_WINDOW_TITLES + .iter() + .map(|title| WindowExclusion { + bundle_identifier: None, + owner_name: None, + window_title: Some((*title).to_string()), + }) + .collect() +} + // When adding fields here, #[serde(default)] defines the value to use for existing configurations, // and `Default::default` defines the value to use for new configurations. // Things that affect the user experience should only be enabled by default for new configurations. @@ -99,6 +124,8 @@ pub struct GeneralSettingsStore { pub post_deletion_behaviour: PostDeletionBehaviour, #[serde(default = "default_enable_new_uploader", skip_serializing_if = "no")] pub enable_new_uploader: bool, + #[serde(default = "default_excluded_windows")] + pub excluded_windows: Vec, } fn default_enable_native_camera_preview() -> bool { @@ -162,6 +189,7 @@ impl Default for GeneralSettingsStore { enable_new_recording_flow: default_enable_new_recording_flow(), post_deletion_behaviour: PostDeletionBehaviour::DoNothing, enable_new_uploader: default_enable_new_uploader(), + excluded_windows: default_excluded_windows(), } } } @@ -213,6 +241,17 @@ impl GeneralSettingsStore { store.set("general_settings", json!(self)); store.save().map_err(|e| e.to_string()) } + + pub fn is_window_excluded( + &self, + bundle_identifier: Option<&str>, + owner_name: Option<&str>, + window_title: Option<&str>, + ) -> bool { + self.excluded_windows + .iter() + .any(|entry| entry.matches(bundle_identifier, owner_name, window_title)) + } } pub fn init(app: &AppHandle) { @@ -231,3 +270,9 @@ pub fn init(app: &AppHandle) { println!("GeneralSettingsState managed"); } + +#[tauri::command] +#[specta::specta] +pub fn get_default_excluded_windows() -> Vec { + default_excluded_windows() +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 58ce372540..a1ed9d02ee 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1914,6 +1914,8 @@ pub async fn run(recording_logging_handle: LoggingHandle) { recording::list_capture_displays, recording::list_displays_with_thumbnails, recording::list_windows_with_thumbnails, + windows::refresh_window_content_protection, + general_settings::get_default_excluded_windows, take_screenshot, list_audio_devices, close_recordings_overlay_window, @@ -2014,7 +2016,8 @@ pub async fn run(recording_logging_handle: LoggingHandle) { .typ::() .typ::() .typ::() - .typ::(); + .typ::() + .typ::(); #[cfg(debug_assertions)] specta_builder diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 625f3ecf6c..7d2472ad04 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -44,7 +44,9 @@ use crate::{ audio::AppSounds, auth::AuthStore, create_screenshot, - general_settings::{GeneralSettingsStore, PostDeletionBehaviour, PostStudioRecordingBehaviour}, + general_settings::{ + self, GeneralSettingsStore, PostDeletionBehaviour, PostStudioRecordingBehaviour, + }, open_external_link, presets::PresetsStore, thumbnails::*, @@ -457,6 +459,11 @@ pub async fn start_recording( recording_dir: recording_dir.clone(), }; + let excluded_windows = general_settings + .as_ref() + .map(|settings| settings.excluded_windows.clone()) + .unwrap_or_else(general_settings::default_excluded_windows); + let actor = match inputs.mode { RecordingMode::Studio => { let mut builder = studio_recording::Actor::builder( @@ -468,7 +475,8 @@ pub async fn start_recording( general_settings .map(|s| s.custom_cursor_capture) .unwrap_or_default(), - ); + ) + .with_excluded_windows(excluded_windows.clone()); if let Some(camera_feed) = camera_feed { builder = builder.with_camera_feed(camera_feed); @@ -508,7 +516,8 @@ pub async fn start_recording( recording_dir.clone(), inputs.capture_target.clone(), ) - .with_system_audio(inputs.capture_system_audio); + .with_system_audio(inputs.capture_system_audio) + .with_excluded_windows(excluded_windows.clone()); if let Some(mic_feed) = mic_feed { builder = builder.with_mic_feed(mic_feed); diff --git a/apps/desktop/src-tauri/src/thumbnails/mod.rs b/apps/desktop/src-tauri/src/thumbnails/mod.rs index c4dbeb9dae..66303520ea 100644 --- a/apps/desktop/src-tauri/src/thumbnails/mod.rs +++ b/apps/desktop/src-tauri/src/thumbnails/mod.rs @@ -33,6 +33,7 @@ pub struct CaptureWindowWithThumbnail { pub refresh_rate: u32, pub thumbnail: Option, pub app_icon: Option, + pub bundle_identifier: Option, } pub fn normalize_thumbnail_dimensions(image: &image::RgbaImage) -> image::RgbaImage { @@ -140,6 +141,7 @@ pub async fn collect_windows_with_thumbnails() -> Result = LogicalPosition::new(12.0, 12.0); @@ -250,6 +251,9 @@ impl ShowCapWindow { .map(|s| s.enable_new_recording_flow) .unwrap_or_default(); + let title = CapWindowId::Main.title(); + let should_protect = should_protect_window(app, &title); + let window = self .window_builder(app, if new_recording_flow { "/new-main" } else { "/" }) .resizable(false) @@ -258,7 +262,7 @@ impl ShowCapWindow { .minimizable(false) .always_on_top(true) .visible_on_all_workspaces(true) - .content_protected(false) + .content_protected(should_protect) .center() .initialization_script(format!( " @@ -296,6 +300,12 @@ impl ShowCapWindow { return Err(tauri::Error::WindowNotFound); }; + let title = CapWindowId::TargetSelectOverlay { + display_id: display_id.clone(), + } + .title(); + let should_protect = should_protect_window(app, &title); + let mut window_builder = self .window_builder( app, @@ -305,7 +315,7 @@ impl ShowCapWindow { .resizable(false) .fullscreen(false) .shadow(false) - .content_protected(true) + .content_protected(should_protect) .always_on_top(true) .visible_on_all_workspaces(true) .skip_taskbar(true) @@ -516,6 +526,12 @@ impl ShowCapWindow { return Err(tauri::Error::WindowNotFound); }; + let title = CapWindowId::WindowCaptureOccluder { + screen_id: screen_id.clone(), + } + .title(); + let should_protect = should_protect_window(app, &title); + #[cfg(target_os = "macos")] let position = display.raw_handle().logical_position(); @@ -532,7 +548,7 @@ impl ShowCapWindow { .shadow(false) .always_on_top(true) .visible_on_all_workspaces(true) - .content_protected(true) + .content_protected(should_protect) .skip_taskbar(true) .inner_size(bounds.width(), bounds.height()) .position(position.x(), position.y()) @@ -550,13 +566,16 @@ impl ShowCapWindow { window } Self::CaptureArea { screen_id } => { + let title = CapWindowId::CaptureArea.title(); + let should_protect = should_protect_window(app, &title); + let mut window_builder = self .window_builder(app, "/capture-area") .maximized(false) .fullscreen(false) .shadow(false) .always_on_top(true) - .content_protected(true) + .content_protected(should_protect) .skip_taskbar(true) .closable(true) .decorations(false) @@ -604,6 +623,9 @@ impl ShowCapWindow { let width = 250.0; let height = 40.0; + let title = CapWindowId::InProgressRecording.title(); + let should_protect = should_protect_window(app, &title); + let window = self .window_builder(app, "/in-progress-recording") .maximized(false) @@ -613,7 +635,7 @@ impl ShowCapWindow { .always_on_top(true) .transparent(true) .visible_on_all_workspaces(true) - .content_protected(true) + .content_protected(should_protect) .inner_size(width, height) .position( ((monitor.size().width as f64) / monitor.scale_factor() - width) / 2.0, @@ -634,6 +656,9 @@ impl ShowCapWindow { window } Self::RecordingsOverlay => { + let title = CapWindowId::RecordingsOverlay.title(); + let should_protect = should_protect_window(app, &title); + let window = self .window_builder(app, "/recordings-overlay") .maximized(false) @@ -643,7 +668,7 @@ impl ShowCapWindow { .always_on_top(true) .visible_on_all_workspaces(true) .accept_first_mouse(true) - .content_protected(true) + .content_protected(should_protect) .inner_size( (monitor.size().width as f64) / monitor.scale_factor(), (monitor.size().height as f64) / monitor.scale_factor(), @@ -840,6 +865,34 @@ fn position_traffic_lights_impl( .ok(); } +fn should_protect_window(app: &AppHandle, window_title: &str) -> bool { + let matches = |list: &[WindowExclusion]| { + list.iter() + .any(|entry| entry.matches(None, None, Some(window_title))) + }; + + GeneralSettingsStore::get(app) + .ok() + .flatten() + .map(|settings| matches(&settings.excluded_windows)) + .unwrap_or_else(|| matches(&general_settings::default_excluded_windows())) +} + +#[tauri::command] +#[specta::specta] +pub fn refresh_window_content_protection(app: AppHandle) -> Result<(), String> { + for (label, window) in app.webview_windows() { + if let Ok(id) = CapWindowId::from_str(&label) { + let title = id.title(); + window + .set_content_protected(should_protect_window(&app, &title)) + .map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + // Credits: tauri-plugin-window-state trait MonitorExt { fn intersects( diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 853973d0e2..dcc61dcf57 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -6,33 +6,145 @@ import { } from "@tauri-apps/plugin-notification"; import { type OsType, type } from "@tauri-apps/plugin-os"; import "@total-typescript/ts-reset/filter-boolean"; -import { CheckMenuItem, Menu } from "@tauri-apps/api/menu"; +import { CheckMenuItem, Menu, MenuItem } from "@tauri-apps/api/menu"; import { confirm } from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; -import { createResource, For, Show } from "solid-js"; -import { createStore } from "solid-js/store"; +import { createEffect, createMemo, createResource, For, Show } from "solid-js"; +import { createStore, reconcile } from "solid-js/store"; import themePreviewAuto from "~/assets/theme-previews/auto.jpg"; import themePreviewDark from "~/assets/theme-previews/dark.jpg"; import themePreviewLight from "~/assets/theme-previews/light.jpg"; import { Input } from "~/routes/editor/ui"; import { authStore, generalSettingsStore } from "~/store"; +import IconLucidePlus from "~icons/lucide/plus"; +import IconLucideX from "~icons/lucide/x"; import { type AppTheme, + type CaptureWindowWithThumbnail, commands, type GeneralSettingsStore, + type WindowExclusion, type MainWindowRecordingStartBehaviour, type PostDeletionBehaviour, type PostStudioRecordingBehaviour, } from "~/utils/tauri"; import { Setting, ToggleSetting } from "./Setting"; +const getExclusionPrimaryLabel = (entry: WindowExclusion) => + entry.ownerName ?? entry.windowTitle ?? entry.bundleIdentifier ?? "Unknown"; + +const getExclusionSecondaryLabel = (entry: WindowExclusion) => { + if (entry.ownerName && entry.windowTitle) { + return entry.windowTitle; + } + + if (entry.bundleIdentifier && (entry.ownerName || entry.windowTitle)) { + return entry.bundleIdentifier; + } + + return entry.bundleIdentifier ?? null; +}; + +const getWindowOptionLabel = (window: CaptureWindowWithThumbnail) => { + const parts = [window.owner_name]; + if (window.name && window.name !== window.owner_name) { + parts.push(window.name); + } + return parts.join(" • "); +}; + +type LegacyWindowExclusion = { + bundle_identifier?: string | null; + owner_name?: string | null; + window_title?: string | null; +}; + +const coerceWindowExclusion = (entry: WindowExclusion | LegacyWindowExclusion): WindowExclusion => { + if (entry && typeof entry === "object") { + if ("bundleIdentifier" in entry || "ownerName" in entry || "windowTitle" in entry) { + const current = entry as WindowExclusion; + return { + bundleIdentifier: current.bundleIdentifier ?? null, + ownerName: current.ownerName ?? null, + windowTitle: current.windowTitle ?? null, + }; + } + + const legacy = entry as LegacyWindowExclusion; + return { + bundleIdentifier: legacy.bundle_identifier ?? null, + ownerName: legacy.owner_name ?? null, + windowTitle: legacy.window_title ?? null, + }; + } + + return { + bundleIdentifier: null, + ownerName: null, + windowTitle: null, + }; +}; + +const normalizeWindowExclusions = ( + entries: (WindowExclusion | LegacyWindowExclusion)[], +): WindowExclusion[] => + entries.map((entry) => { + const coerced = coerceWindowExclusion(entry); + const bundleIdentifier = coerced.bundleIdentifier ?? null; + const ownerName = coerced.ownerName ?? null; + const hasBundleIdentifier = + typeof bundleIdentifier === "string" && bundleIdentifier.length > 0; + return { + bundleIdentifier, + ownerName, + windowTitle: hasBundleIdentifier ? null : coerced.windowTitle ?? null, + } satisfies WindowExclusion; + }); + +const createDefaultGeneralSettings = (): GeneralSettingsStore => ({ + uploadIndividualFiles: false, + hideDockIcon: false, + autoCreateShareableLink: false, + enableNotifications: true, + enableNativeCameraPreview: false, + enableNewRecordingFlow: false, + autoZoomOnClicks: false, + custom_cursor_capture2: true, + enableNewUploader: false, + excludedWindows: normalizeWindowExclusions([]), +}); + +const deriveInitialSettings = ( + store: GeneralSettingsStore | null, +): GeneralSettingsStore => { + const defaults = createDefaultGeneralSettings(); + if (!store) return defaults; + + const { excluded_windows, ...rest } = store as GeneralSettingsStore & { + excluded_windows?: LegacyWindowExclusion[]; + }; + + const rawExcludedWindows = ( + store.excludedWindows ?? excluded_windows ?? [] + ) as (WindowExclusion | LegacyWindowExclusion)[]; + + return { + ...defaults, + ...rest, + excludedWindows: normalizeWindowExclusions(rawExcludedWindows), + }; +}; + export default function GeneralSettings() { - const [store] = createResource(() => generalSettingsStore.get()); + const [store] = createResource(generalSettingsStore.get, { + initialValue: null, + }); return ( - - {(store) => } - + ); } @@ -107,21 +219,22 @@ function AppearanceSection(props: { ); } -function Inner(props: { initialStore: GeneralSettingsStore | null }) { +function Inner(props: { + initialStore: GeneralSettingsStore | null; + isStoreLoading: boolean; +}) { const [settings, setSettings] = createStore( - props.initialStore ?? { - uploadIndividualFiles: false, - hideDockIcon: false, - autoCreateShareableLink: false, - enableNotifications: true, - enableNativeCameraPreview: false, - enableNewRecordingFlow: false, - autoZoomOnClicks: false, - custom_cursor_capture2: true, - enableNewUploader: false, - }, + deriveInitialSettings(props.initialStore), ); + createEffect(() => { + setSettings(reconcile(deriveInitialSettings(props.initialStore))); + }); + + const [windows] = createResource(commands.listWindowsWithThumbnails, { + initialValue: [] as CaptureWindowWithThumbnail[], + }); + const handleChange = async ( key: K, value: (typeof settings)[K], @@ -133,6 +246,92 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { }; const ostype: OsType = type(); + const excludedWindows = createMemo(() => settings.excludedWindows ?? []); + const isExcludedWindowsLoading = createMemo( + () => props.isStoreLoading || windows.loading, + ); + + const matchesExclusion = (exclusion: WindowExclusion, window: CaptureWindowWithThumbnail) => { + const bundleMatch = exclusion.bundleIdentifier + ? window.bundle_identifier === exclusion.bundleIdentifier + : false; + if (bundleMatch) return true; + + const ownerMatch = exclusion.ownerName + ? window.owner_name === exclusion.ownerName + : false; + + if (exclusion.ownerName && exclusion.windowTitle) { + return ownerMatch && window.name === exclusion.windowTitle; + } + + if (ownerMatch && exclusion.ownerName) { + return true; + } + + if (exclusion.windowTitle) { + return window.name === exclusion.windowTitle; + } + + return false; + }; + + const isManagedWindowsApp = (window: CaptureWindowWithThumbnail) => { + const bundle = window.bundle_identifier?.toLowerCase() ?? ""; + if (bundle.includes("so.cap.desktop")) { + return true; + } + return window.owner_name.toLowerCase().includes("cap"); + }; + + const availableWindows = createMemo(() => { + const data = windows() ?? []; + return data.filter((window) => { + if (excludedWindows().some((entry) => matchesExclusion(entry, window))) { + return false; + } + if (ostype === "windows") { + return isManagedWindowsApp(window); + } + return true; + }); + }); + + const applyExcludedWindows = async (next: WindowExclusion[]) => { + const normalized = normalizeWindowExclusions(next); + setSettings("excludedWindows", normalized); + try { + await generalSettingsStore.set({ excludedWindows: normalized }); + await commands.refreshWindowContentProtection(); + } catch (error) { + console.error("Failed to update excluded windows", error); + } + }; + + const handleRemoveExclusion = async (index: number) => { + const current = [...excludedWindows()]; + current.splice(index, 1); + await applyExcludedWindows(current); + }; + + const handleAddWindow = async (window: CaptureWindowWithThumbnail) => { + const windowTitle = window.bundle_identifier ? null : window.name; + + const next = [ + ...excludedWindows(), + { + bundleIdentifier: window.bundle_identifier ?? null, + ownerName: window.owner_name ?? null, + windowTitle, + }, + ]; + await applyExcludedWindows(next); + }; + + const handleResetExclusions = async () => { + const defaults = await commands.getDefaultExcludedWindows(); + await applyExcludedWindows(defaults); + }; type ToggleSettingItem = { label: string; @@ -483,10 +682,20 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { - + )} + + { @@ -545,3 +754,138 @@ function ServerURLSetting(props: { ); } + +function ExcludedWindowsCard(props: { + excludedWindows: WindowExclusion[]; + availableWindows: CaptureWindowWithThumbnail[]; + onRemove: (index: number) => Promise; + onAdd: (window: CaptureWindowWithThumbnail) => Promise; + onReset: () => Promise; + isLoading: boolean; + isWindows: boolean; +}) { + const hasExclusions = () => props.excludedWindows.length > 0; + const canAdd = () => props.availableWindows.length > 0 && !props.isLoading; + + const handleAddClick = async () => { + if (!canAdd()) return; + + const items = await Promise.all( + props.availableWindows.map((window) => + MenuItem.new({ + text: getWindowOptionLabel(window), + action: () => { + void props.onAdd(window); + }, + }), + ), + ); + + const menu = await Menu.new({ items }); + await menu.popup(); + await menu.close(); + }; + + return ( +
+
+
+

Excluded Windows

+

+ Choose which windows Cap hides from your recordings. +

+ +

+ Only Cap windows can be excluded on Windows. +

+
+
+
+ + +
+
+ } + > + + No windows are currently excluded. +

+ } + > +
+ + {(entry, index) => ( +
+
+ + {getExclusionPrimaryLabel(entry)} + + + {(label) => ( + + {label()} + + )} + +
+ +
+ )} +
+
+
+
+
+ ); +} + +function ExcludedWindowsSkeleton() { + const chipWidths = ["w-32", "w-28", "w-36"] as const; + + return ( +
- } - > + }> Date: Mon, 13 Oct 2025 17:07:22 +0100 Subject: [PATCH 3/9] fix: bundle_identifier on Windows --- crates/scap-targets/src/platform/win.rs | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/scap-targets/src/platform/win.rs b/crates/scap-targets/src/platform/win.rs index 817a230dd6..4d8521e9ab 100644 --- a/crates/scap-targets/src/platform/win.rs +++ b/crates/scap-targets/src/platform/win.rs @@ -482,6 +482,45 @@ impl WindowImpl { } } + pub fn bundle_identifier(&self) -> Option { + // On Windows, use the executable name as the bundle identifier + // This is similar to the macOS bundle identifier concept + unsafe { + let mut process_id = 0u32; + GetWindowThreadProcessId(self.0, Some(&mut process_id)); + + if process_id == 0 { + return None; + } + + let process_handle = + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_id).ok()?; + + let mut buffer = [0u16; 1024]; + let mut buffer_size = buffer.len() as u32; + + let result = QueryFullProcessImageNameW( + process_handle, + PROCESS_NAME_FORMAT::default(), + PWSTR(buffer.as_mut_ptr()), + &mut buffer_size, + ); + + let _ = CloseHandle(process_handle); + + if result.is_ok() && buffer_size > 0 { + let path_str = String::from_utf16_lossy(&buffer[..buffer_size as usize]); + + // Return the executable name (without extension) as the bundle identifier + std::path::Path::new(&path_str) + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + } else { + None + } + } + } + fn get_file_description(&self, file_path: &str) -> Option { unsafe { let wide_path: Vec = file_path.encode_utf16().chain(std::iter::once(0)).collect(); From cc9aa2ed053d94756216962f9389814c9020c729 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:45:24 +0100 Subject: [PATCH 4/9] feat: Implement CR suggestions + move definition to desktop --- .claude/settings.local.json | 22 ++--- .../desktop/src-tauri/src/general_settings.rs | 2 +- apps/desktop/src-tauri/src/lib.rs | 3 +- apps/desktop/src-tauri/src/recording.rs | 12 ++- .../desktop/src-tauri/src/window_exclusion.rs | 95 +++++++++++++++++++ apps/desktop/src-tauri/src/windows.rs | 2 +- crates/recording/src/capture_pipeline.rs | 5 +- crates/recording/src/instant_recording.rs | 7 +- crates/recording/src/lib.rs | 9 +- .../src/sources/screen_capture/macos.rs | 41 +++----- .../src/sources/screen_capture/mod.rs | 64 +------------ crates/recording/src/studio_recording.rs | 8 +- crates/scap-targets/src/platform/macos.rs | 19 ++-- 13 files changed, 155 insertions(+), 134 deletions(-) create mode 100644 apps/desktop/src-tauri/src/window_exclusion.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5503f40598..ce1d28dd79 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,12 +1,12 @@ { - "permissions": { - "allow": [ - "Bash(pnpm typecheck:*)", - "Bash(pnpm lint:*)", - "Bash(pnpm build:*)", - "Bash(cargo check:*)" - ], - "deny": [], - "ask": [] - } -} \ No newline at end of file + "permissions": { + "allow": [ + "Bash(pnpm typecheck:*)", + "Bash(pnpm lint:*)", + "Bash(pnpm build:*)", + "Bash(cargo check:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 9ffe29856a..283beb1202 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -1,4 +1,4 @@ -use cap_recording::sources::screen_capture::WindowExclusion; +use crate::window_exclusion::WindowExclusion; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a1ed9d02ee..b65c9edc67 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod tray; mod upload; mod upload_legacy; mod web_api; +mod window_exclusion; mod windows; use audio::AppSounds; @@ -2017,7 +2018,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) { .typ::() .typ::() .typ::() - .typ::(); + .typ::(); #[cfg(debug_assertions)] specta_builder diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 7d2472ad04..efa61b5710 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -459,10 +459,14 @@ pub async fn start_recording( recording_dir: recording_dir.clone(), }; - let excluded_windows = general_settings + let window_exclusions = general_settings .as_ref() - .map(|settings| settings.excluded_windows.clone()) - .unwrap_or_else(general_settings::default_excluded_windows); + .map_or_else(general_settings::default_excluded_windows, |settings| { + settings.excluded_windows.clone() + }); + + let excluded_windows = + crate::window_exclusion::resolve_window_ids(&window_exclusions); let actor = match inputs.mode { RecordingMode::Studio => { @@ -707,7 +711,7 @@ pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> R } }; - if let Some((recording, recording_dir, video_id)) = recording_data { + if let Some((_, recording_dir, video_id)) = recording_data { CurrentRecordingChanged.emit(&app).ok(); RecordingStopped {}.emit(&app).ok(); diff --git a/apps/desktop/src-tauri/src/window_exclusion.rs b/apps/desktop/src-tauri/src/window_exclusion.rs new file mode 100644 index 0000000000..08981aa207 --- /dev/null +++ b/apps/desktop/src-tauri/src/window_exclusion.rs @@ -0,0 +1,95 @@ +use scap_targets::Window; +use scap_targets::WindowId; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WindowExclusion { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bundle_identifier: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window_title: Option, +} + +impl WindowExclusion { + pub fn matches( + &self, + bundle_identifier: Option<&str>, + owner_name: Option<&str>, + window_title: Option<&str>, + ) -> bool { + if let Some(identifier) = self.bundle_identifier.as_deref() { + if bundle_identifier + .map(|candidate| candidate == identifier) + .unwrap_or(false) + { + return true; + } + } + + if let Some(expected_owner) = self.owner_name.as_deref() { + let owner_matches = owner_name + .map(|candidate| candidate == expected_owner) + .unwrap_or(false); + + if self.window_title.is_some() { + return owner_matches + && self + .window_title + .as_deref() + .map(|expected_title| { + window_title + .map(|candidate| candidate == expected_title) + .unwrap_or(false) + }) + .unwrap_or(false); + } + + if owner_matches { + return true; + } + } + + if let Some(expected_title) = self.window_title.as_deref() { + return window_title + .map(|candidate| candidate == expected_title) + .unwrap_or(false); + } + + false + } +} + +pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec { + if exclusions.is_empty() { + return Vec::new(); + } + + Window::list() + .into_iter() + .filter_map(|window| { + let owner_name = window.owner_name(); + let window_title = window.name(); + + #[cfg(target_os = "macos")] + let bundle_identifier = window.bundle_identifier(); + + #[cfg(not(target_os = "macos"))] + let bundle_identifier = None; + + exclusions + .iter() + .find(|entry| { + entry.matches( + bundle_identifier.as_deref(), + owner_name.as_deref(), + window_title.as_deref(), + ) + }) + .map(|_| window.id()) + }) + .collect() +} diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 4954b09009..b5d5f2d030 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -26,8 +26,8 @@ use crate::{ permissions, recording_settings::RecordingTargetMode, target_select_overlay::WindowFocusManager, + window_exclusion::WindowExclusion, }; -use cap_recording::sources::screen_capture::WindowExclusion; #[cfg(target_os = "macos")] const DEFAULT_TRAFFIC_LIGHTS_INSET: LogicalPosition = LogicalPosition::new(12.0, 12.0); diff --git a/crates/recording/src/capture_pipeline.rs b/crates/recording/src/capture_pipeline.rs index 328d0ef325..33d12c3a20 100644 --- a/crates/recording/src/capture_pipeline.rs +++ b/crates/recording/src/capture_pipeline.rs @@ -3,10 +3,11 @@ use crate::{ output_pipeline::*, sources, sources::screen_capture::{ - self, ScreenCaptureConfig, ScreenCaptureFormat, ScreenCaptureTarget, WindowExclusion, + self, ScreenCaptureConfig, ScreenCaptureFormat, ScreenCaptureTarget, }, }; use cap_timestamp::Timestamps; +use scap_targets::WindowId; use std::{path::PathBuf, sync::Arc, time::SystemTime}; pub trait MakeCapturePipeline: ScreenCaptureFormat + std::fmt::Debug + 'static { @@ -134,7 +135,7 @@ pub async fn create_screen_capture( system_audio: bool, #[cfg(windows)] d3d_device: ::windows::Win32::Graphics::Direct3D11::ID3D11Device, #[cfg(target_os = "macos")] shareable_content: cidre::arc::R, - excluded_windows: &[WindowExclusion], + excluded_windows: &[WindowId], ) -> anyhow::Result> { Ok(ScreenCaptureConfig::::init( capture_target, diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index e29ab77b8f..86f22e44cb 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -3,12 +3,13 @@ use crate::{ capture_pipeline::{MakeCapturePipeline, ScreenCaptureMethod, Stop, create_screen_capture}, feeds::microphone::MicrophoneFeedLock, output_pipeline::{self, OutputPipeline}, - sources::screen_capture::{ScreenCaptureConfig, ScreenCaptureTarget, WindowExclusion}, + sources::screen_capture::{ScreenCaptureConfig, ScreenCaptureTarget}, }; use cap_media_info::{AudioInfo, VideoInfo}; use cap_project::InstantRecordingMeta; use cap_utils::ensure_dir; use kameo::{Actor as _, prelude::*}; +use scap_targets::WindowId; use std::{ path::PathBuf, sync::Arc, @@ -220,7 +221,7 @@ pub struct ActorBuilder { capture_target: ScreenCaptureTarget, system_audio: bool, mic_feed: Option>, - excluded_windows: Vec, + excluded_windows: Vec, } impl ActorBuilder { @@ -244,7 +245,7 @@ impl ActorBuilder { self } - pub fn with_excluded_windows(mut self, excluded_windows: Vec) -> Self { + pub fn with_excluded_windows(mut self, excluded_windows: Vec) -> Self { self.excluded_windows = excluded_windows; self } diff --git a/crates/recording/src/lib.rs b/crates/recording/src/lib.rs index 3f3c5b125b..b9a12a8f61 100644 --- a/crates/recording/src/lib.rs +++ b/crates/recording/src/lib.rs @@ -13,15 +13,12 @@ pub use sources::screen_capture; use cap_media::MediaError; use feeds::microphone::MicrophoneFeedLock; -use scap_targets::bounds::LogicalBounds; +use scap_targets::{WindowId, bounds::LogicalBounds}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use thiserror::Error; -use crate::{ - feeds::camera::CameraFeedLock, - sources::screen_capture::{ScreenCaptureTarget, WindowExclusion}, -}; +use crate::{feeds::camera::CameraFeedLock, sources::screen_capture::ScreenCaptureTarget}; #[derive(specta::Type, Serialize, Deserialize, Clone, Debug, Copy, Default)] #[serde(rename_all = "camelCase")] @@ -51,7 +48,7 @@ pub struct RecordingBaseInputs { pub camera_feed: Option>, #[cfg(target_os = "macos")] pub shareable_content: cidre::arc::R, - pub excluded_windows: Vec, + pub excluded_windows: Vec, } #[derive(specta::Type, Serialize, Deserialize, Clone, Debug)] diff --git a/crates/recording/src/sources/screen_capture/macos.rs b/crates/recording/src/sources/screen_capture/macos.rs index bcfec7118a..c860b0cf92 100644 --- a/crates/recording/src/sources/screen_capture/macos.rs +++ b/crates/recording/src/sources/screen_capture/macos.rs @@ -9,12 +9,9 @@ use crate::{ use anyhow::{Context, anyhow}; use cidre::*; use futures::{FutureExt, channel::mpsc, future::BoxFuture}; -use std::{ - collections::HashSet, - sync::{ - Arc, - atomic::{self, AtomicBool}, - }, +use std::sync::{ + Arc, + atomic::{self, AtomicBool}, }; use tokio::sync::broadcast; use tracing::debug; @@ -67,7 +64,7 @@ impl ScreenCaptureConfig { &self, ) -> anyhow::Result<(VideoSourceConfig, Option)> { let (error_tx, error_rx) = broadcast::channel(1); - let (mut video_tx, video_rx) = flume::bounded(4); + let (video_tx, video_rx) = flume::bounded(4); let (mut audio_tx, audio_rx) = if self.system_audio { let (tx, rx) = mpsc::channel(32); (Some(tx), Some(rx)) @@ -83,25 +80,17 @@ impl ScreenCaptureConfig { } else { let mut collected = Vec::new(); - for window in Window::list() { - let owner_name = window.owner_name(); - let window_title = window.name(); - let bundle_identifier = window.bundle_identifier(); - - if self.excluded_windows.iter().any(|entry| { - entry.matches( - bundle_identifier.as_deref(), - owner_name.as_deref(), - window_title.as_deref(), - ) - }) { - if let Some(sc_window) = window - .raw_handle() - .as_sc(self.shareable_content.clone()) - .await - { - collected.push(sc_window); - } + for window_id in &self.excluded_windows { + let Some(window) = Window::from_id(window_id) else { + continue; + }; + + if let Some(sc_window) = window + .raw_handle() + .as_sc(self.shareable_content.clone()) + .await + { + collected.push(sc_window); } } diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs index af82aef42b..526e366dc4 100644 --- a/crates/recording/src/sources/screen_capture/mod.rs +++ b/crates/recording/src/sources/screen_capture/mod.rs @@ -48,66 +48,6 @@ pub struct CaptureArea { pub bounds: LogicalBounds, } -#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WindowExclusion { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bundle_identifier: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub owner_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub window_title: Option, -} - -impl WindowExclusion { - pub fn matches( - &self, - bundle_identifier: Option<&str>, - owner_name: Option<&str>, - window_title: Option<&str>, - ) -> bool { - if let Some(identifier) = self.bundle_identifier.as_deref() { - if bundle_identifier - .map(|candidate| candidate == identifier) - .unwrap_or(false) - { - return true; - } - } - - if let Some(expected_owner) = self.owner_name.as_deref() { - let owner_matches = owner_name - .map(|candidate| candidate == expected_owner) - .unwrap_or(false); - - if self.window_title.is_some() { - return owner_matches - && self - .window_title - .as_deref() - .map(|expected_title| { - window_title - .map(|candidate| candidate == expected_title) - .unwrap_or(false) - }) - .unwrap_or(false); - } - - if owner_matches { - return true; - } - } - - if let Some(expected_title) = self.window_title.as_deref() { - return window_title - .map(|candidate| candidate == expected_title) - .unwrap_or(false); - } - - false - } -} - #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase", tag = "variant")] pub enum ScreenCaptureTarget { @@ -259,7 +199,7 @@ pub struct ScreenCaptureConfig { d3d_device: ::windows::Win32::Graphics::Direct3D11::ID3D11Device, #[cfg(target_os = "macos")] shareable_content: cidre::arc::R, - pub excluded_windows: Vec, + pub excluded_windows: Vec, } impl std::fmt::Debug for ScreenCaptureConfig { @@ -338,7 +278,7 @@ impl ScreenCaptureConfig { system_audio: bool, #[cfg(windows)] d3d_device: ::windows::Win32::Graphics::Direct3D11::ID3D11Device, #[cfg(target_os = "macos")] shareable_content: cidre::arc::R, - excluded_windows: Vec, + excluded_windows: Vec, ) -> Result { cap_fail::fail!("ScreenCaptureSource::init"); diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index f1fd85f692..c9420f5daa 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -14,6 +14,7 @@ use cap_timestamp::{Timestamp, Timestamps}; use futures::{FutureExt, StreamExt, future::OptionFuture, stream::FuturesUnordered}; use kameo::{Actor as _, prelude::*}; use relative_path::RelativePathBuf; +use scap_targets::WindowId; use std::{ path::{Path, PathBuf}, sync::Arc, @@ -344,7 +345,7 @@ pub struct ActorBuilder { mic_feed: Option>, camera_feed: Option>, custom_cursor: bool, - excluded_windows: Vec, + excluded_windows: Vec, } impl ActorBuilder { @@ -380,10 +381,7 @@ impl ActorBuilder { self } - pub fn with_excluded_windows( - mut self, - excluded_windows: Vec, - ) -> Self { + pub fn with_excluded_windows(mut self, excluded_windows: Vec) -> Self { self.excluded_windows = excluded_windows; self } diff --git a/crates/scap-targets/src/platform/macos.rs b/crates/scap-targets/src/platform/macos.rs index e3b87326b5..c1a66b1328 100644 --- a/crates/scap-targets/src/platform/macos.rs +++ b/crates/scap-targets/src/platform/macos.rs @@ -350,7 +350,9 @@ impl WindowImpl { pub fn bundle_identifier(&self) -> Option { let pid = self.owner_pid()?; - unsafe { + use objc::rc::autoreleasepool; + + autoreleasepool(|| unsafe { use cocoa::base::id; use cocoa::foundation::NSString; use objc::{class, msg_send, sel, sel_impl}; @@ -359,27 +361,20 @@ impl WindowImpl { class!(NSRunningApplication), runningApplicationWithProcessIdentifier: pid ]; - - if app.is_null() { - return None; - } + let app = (!app.is_null()).then_some(app)?; let bundle_identifier: id = msg_send![app, bundleIdentifier]; - if bundle_identifier.is_null() { - return None; - } + let bundle_identifier = (!bundle_identifier.is_null()).then_some(bundle_identifier)?; let cstr = NSString::UTF8String(bundle_identifier); - if cstr.is_null() { - return None; - } + let cstr = (!cstr.is_null()).then_some(cstr)?; Some( std::ffi::CStr::from_ptr(cstr) .to_string_lossy() .into_owned(), ) - } + }) } pub fn name(&self) -> Option { From d5ddfd05fd9668ed04c796ee5000a19812267d5a Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 14 Oct 2025 02:06:17 +0800 Subject: [PATCH 5/9] cleanup --- .../desktop/src-tauri/src/general_settings.rs | 2 +- apps/desktop/src-tauri/src/lib.rs | 2 +- apps/desktop/src-tauri/src/recording.rs | 8 +- apps/desktop/src-tauri/src/windows.rs | 15 +-- .../(window-chrome)/settings/general.tsx | 97 +++---------------- apps/desktop/src/routes/debug.tsx | 2 +- crates/recording/src/capture_pipeline.rs | 5 +- crates/recording/src/instant_recording.rs | 7 +- crates/recording/src/lib.rs | 1 + .../src/sources/screen_capture/mod.rs | 5 +- crates/recording/src/studio_recording.rs | 7 +- crates/scap-targets/src/lib.rs | 4 - crates/scap-targets/src/platform/win.rs | 39 -------- 13 files changed, 47 insertions(+), 147 deletions(-) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 283beb1202..8d2595d5aa 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -43,7 +43,7 @@ impl MainWindowRecordingStartBehaviour { const DEFAULT_EXCLUDED_WINDOW_TITLES: &[&str] = &[ "Cap", "Cap Settings", - "Cap In Progress Recording", + "Cap Recording Controls", "Cap Camera", ]; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4ae59a4930..05289bd2d9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2121,7 +2121,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) { CapWindowId::CaptureArea.label().as_str(), CapWindowId::Camera.label().as_str(), CapWindowId::RecordingsOverlay.label().as_str(), - CapWindowId::InProgressRecording.label().as_str(), + CapWindowId::RecordingControls.label().as_str(), CapWindowId::Upgrade.label().as_str(), ]) .map_label(|label| match label { diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index f73ea56504..863c07fecf 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -589,7 +589,7 @@ pub async fn start_recording( ) .kind(tauri_plugin_dialog::MessageDialogKind::Error); - if let Some(window) = CapWindowId::InProgressRecording.get(&app) { + if let Some(window) = CapWindowId::RecordingControls.get(&app) { dialog = dialog.parent(&window); } @@ -631,7 +631,7 @@ pub async fn start_recording( ) .kind(tauri_plugin_dialog::MessageDialogKind::Error); - if let Some(window) = CapWindowId::InProgressRecording.get(&app) { + if let Some(window) = CapWindowId::RecordingControls.get(&app) { dialog = dialog.parent(&window); } @@ -754,7 +754,7 @@ pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> R .flatten() .unwrap_or_default(); - if let Some(window) = CapWindowId::InProgressRecording.get(&app) { + if let Some(window) = CapWindowId::RecordingControls.get(&app) { let _ = window.close(); } @@ -818,7 +818,7 @@ async fn handle_recording_end( let _ = app.recording_logging_handle.reload(None); - if let Some(window) = CapWindowId::InProgressRecording.get(&handle) { + if let Some(window) = CapWindowId::RecordingControls.get(&handle) { let _ = window.close(); } diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index b5d5f2d030..5a5b113974 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -44,7 +44,7 @@ pub enum CapWindowId { TargetSelectOverlay { display_id: DisplayId }, CaptureArea, Camera, - InProgressRecording, + RecordingControls, Upgrade, ModeSelect, Debug, @@ -60,7 +60,8 @@ impl FromStr for CapWindowId { "settings" => Self::Settings, "camera" => Self::Camera, "capture-area" => Self::CaptureArea, - "in-progress-recording" => Self::InProgressRecording, + // legacy identifier + "in-progress-recording" => Self::RecordingControls, "recordings-overlay" => Self::RecordingsOverlay, "upgrade" => Self::Upgrade, "mode-select" => Self::ModeSelect, @@ -102,7 +103,7 @@ impl std::fmt::Display for CapWindowId { Self::TargetSelectOverlay { display_id } => { write!(f, "target-select-overlay-{display_id}") } - Self::InProgressRecording => write!(f, "in-progress-recording"), + Self::RecordingControls => write!(f, "in-progress-recording"), // legacy identifier Self::RecordingsOverlay => write!(f, "recordings-overlay"), Self::Upgrade => write!(f, "upgrade"), Self::ModeSelect => write!(f, "mode-select"), @@ -123,7 +124,7 @@ impl CapWindowId { Self::Settings => "Cap Settings".to_string(), Self::WindowCaptureOccluder { .. } => "Cap Window Capture Occluder".to_string(), Self::CaptureArea => "Cap Capture Area".to_string(), - Self::InProgressRecording => "Cap In Progress Recording".to_string(), + Self::RecordingControls => "Cap Recording Controls".to_string(), Self::Editor { .. } => "Cap Editor".to_string(), Self::ModeSelect => "Cap Mode Selection".to_string(), Self::Camera => "Cap Camera".to_string(), @@ -153,7 +154,7 @@ impl CapWindowId { pub fn traffic_lights_position(&self) -> Option>> { match self { Self::Editor { .. } => Some(Some(LogicalPosition::new(20.0, 32.0))), - Self::InProgressRecording => Some(Some(LogicalPosition::new(-100.0, -100.0))), + Self::RecordingControls => Some(Some(LogicalPosition::new(-100.0, -100.0))), Self::Camera | Self::WindowCaptureOccluder { .. } | Self::CaptureArea @@ -623,7 +624,7 @@ impl ShowCapWindow { let width = 250.0; let height = 40.0; - let title = CapWindowId::InProgressRecording.title(); + let title = CapWindowId::RecordingControls.title(); let should_protect = should_protect_window(app, &title); let window = self @@ -783,7 +784,7 @@ impl ShowCapWindow { } ShowCapWindow::CaptureArea { .. } => CapWindowId::CaptureArea, ShowCapWindow::Camera => CapWindowId::Camera, - ShowCapWindow::InProgressRecording { .. } => CapWindowId::InProgressRecording, + ShowCapWindow::InProgressRecording { .. } => CapWindowId::RecordingControls, ShowCapWindow::Upgrade => CapWindowId::Upgrade, ShowCapWindow::ModeSelect => CapWindowId::ModeSelect, } diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index b860d788db..789986f5ad 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -61,60 +61,6 @@ const getWindowOptionLabel = (window: CaptureWindow) => { return parts.join(" • "); }; -type LegacyWindowExclusion = { - bundle_identifier?: string | null; - owner_name?: string | null; - window_title?: string | null; -}; - -const coerceWindowExclusion = ( - entry: WindowExclusion | LegacyWindowExclusion, -): WindowExclusion => { - if (entry && typeof entry === "object") { - if ( - "bundleIdentifier" in entry || - "ownerName" in entry || - "windowTitle" in entry - ) { - const current = entry as WindowExclusion; - return { - bundleIdentifier: current.bundleIdentifier ?? null, - ownerName: current.ownerName ?? null, - windowTitle: current.windowTitle ?? null, - }; - } - - const legacy = entry as LegacyWindowExclusion; - return { - bundleIdentifier: legacy.bundle_identifier ?? null, - ownerName: legacy.owner_name ?? null, - windowTitle: legacy.window_title ?? null, - }; - } - - return { - bundleIdentifier: null, - ownerName: null, - windowTitle: null, - }; -}; - -const normalizeWindowExclusions = ( - entries: (WindowExclusion | LegacyWindowExclusion)[], -): WindowExclusion[] => - entries.map((entry) => { - const coerced = coerceWindowExclusion(entry); - const bundleIdentifier = coerced.bundleIdentifier ?? null; - const ownerName = coerced.ownerName ?? null; - const hasBundleIdentifier = - typeof bundleIdentifier === "string" && bundleIdentifier.length > 0; - return { - bundleIdentifier, - ownerName, - windowTitle: hasBundleIdentifier ? null : (coerced.windowTitle ?? null), - } satisfies WindowExclusion; - }); - const createDefaultGeneralSettings = (): GeneralSettingsStore => ({ uploadIndividualFiles: false, hideDockIcon: false, @@ -125,7 +71,7 @@ const createDefaultGeneralSettings = (): GeneralSettingsStore => ({ autoZoomOnClicks: false, custom_cursor_capture2: true, enableNewUploader: false, - excludedWindows: normalizeWindowExclusions([]), + excludedWindows: [], }); const deriveInitialSettings = ( @@ -134,31 +80,19 @@ const deriveInitialSettings = ( const defaults = createDefaultGeneralSettings(); if (!store) return defaults; - const { excluded_windows, ...rest } = store as GeneralSettingsStore & { - excluded_windows?: LegacyWindowExclusion[]; - }; - - const rawExcludedWindows = (store.excludedWindows ?? - excluded_windows ?? - []) as (WindowExclusion | LegacyWindowExclusion)[]; - return { ...defaults, - ...rest, - excludedWindows: normalizeWindowExclusions(rawExcludedWindows), + ...store, }; }; export default function GeneralSettings() { - const [store] = createResource(generalSettingsStore.get, { - initialValue: undefined, - }); + const [store] = createResource(() => generalSettingsStore.get()); return ( - + + {(store) => } + ); } @@ -233,10 +167,7 @@ function AppearanceSection(props: { ); } -function Inner(props: { - initialStore: GeneralSettingsStore | null; - isStoreLoading: boolean; -}) { +function Inner(props: { initialStore: GeneralSettingsStore | null }) { const [settings, setSettings] = createStore( deriveInitialSettings(props.initialStore), ); @@ -268,9 +199,6 @@ function Inner(props: { const ostype: OsType = type(); const excludedWindows = createMemo(() => settings.excludedWindows ?? []); - const isExcludedWindowsLoading = createMemo( - () => props.isStoreLoading || windows.loading, - ); const matchesExclusion = ( exclusion: WindowExclusion, @@ -333,11 +261,10 @@ function Inner(props: { } }; - const applyExcludedWindows = async (next: WindowExclusion[]) => { - const normalized = normalizeWindowExclusions(next); - setSettings("excludedWindows", normalized); + const applyExcludedWindows = async (windows: WindowExclusion[]) => { + setSettings("excludedWindows", windows); try { - await generalSettingsStore.set({ excludedWindows: normalized }); + await generalSettingsStore.set({ excludedWindows: windows }); await commands.refreshWindowContentProtection(); if (ostype === "macos") { await events.requestScreenCapturePrewarm.emit({ force: true }); @@ -732,7 +659,7 @@ function Inner(props: { onRemove={handleRemoveExclusion} onAdd={handleAddWindow} onReset={handleResetExclusions} - isLoading={isExcludedWindowsLoading()} + isLoading={windows.loading} isWindows={ostype === "windows"} /> @@ -887,7 +814,7 @@ function ExcludedWindowsCard(props: { void props.onReset(); }} > - Set to Default + Reset to Default diff --git a/crates/recording/src/capture_pipeline.rs b/crates/recording/src/capture_pipeline.rs index 33d12c3a20..2947ce59bd 100644 --- a/crates/recording/src/capture_pipeline.rs +++ b/crates/recording/src/capture_pipeline.rs @@ -135,7 +135,7 @@ pub async fn create_screen_capture( system_audio: bool, #[cfg(windows)] d3d_device: ::windows::Win32::Graphics::Direct3D11::ID3D11Device, #[cfg(target_os = "macos")] shareable_content: cidre::arc::R, - excluded_windows: &[WindowId], + #[cfg(target_os = "macos")] excluded_windows: Vec, ) -> anyhow::Result> { Ok(ScreenCaptureConfig::::init( capture_target, @@ -147,7 +147,8 @@ pub async fn create_screen_capture( d3d_device, #[cfg(target_os = "macos")] shareable_content, - excluded_windows.to_vec(), + #[cfg(target_os = "macos")] + excluded_windows, ) .await?) } diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index 86f22e44cb..0b8f4290cb 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -221,6 +221,7 @@ pub struct ActorBuilder { capture_target: ScreenCaptureTarget, system_audio: bool, mic_feed: Option>, + #[cfg(target_os = "macos")] excluded_windows: Vec, } @@ -231,6 +232,7 @@ impl ActorBuilder { capture_target, system_audio: false, mic_feed: None, + #[cfg(target_os = "macos")] excluded_windows: Vec::new(), } } @@ -245,6 +247,7 @@ impl ActorBuilder { self } + #[cfg(target_os = "macos")] pub fn with_excluded_windows(mut self, excluded_windows: Vec) -> Self { self.excluded_windows = excluded_windows; self @@ -263,6 +266,7 @@ impl ActorBuilder { camera_feed: None, #[cfg(target_os = "macos")] shareable_content, + #[cfg(target_os = "macos")] excluded_windows: self.excluded_windows, }, ) @@ -299,7 +303,8 @@ pub async fn spawn_instant_recording_actor( d3d_device, #[cfg(target_os = "macos")] inputs.shareable_content.retained(), - &inputs.excluded_windows, + #[cfg(target_os = "macos")] + inputs.excluded_windows, ) .await?; diff --git a/crates/recording/src/lib.rs b/crates/recording/src/lib.rs index b9a12a8f61..59de64d171 100644 --- a/crates/recording/src/lib.rs +++ b/crates/recording/src/lib.rs @@ -48,6 +48,7 @@ pub struct RecordingBaseInputs { pub camera_feed: Option>, #[cfg(target_os = "macos")] pub shareable_content: cidre::arc::R, + #[cfg(target_os = "macos")] pub excluded_windows: Vec, } diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs index 526e366dc4..24dccd9b73 100644 --- a/crates/recording/src/sources/screen_capture/mod.rs +++ b/crates/recording/src/sources/screen_capture/mod.rs @@ -199,6 +199,7 @@ pub struct ScreenCaptureConfig { d3d_device: ::windows::Win32::Graphics::Direct3D11::ID3D11Device, #[cfg(target_os = "macos")] shareable_content: cidre::arc::R, + #[cfg(target_os = "macos")] pub excluded_windows: Vec, } @@ -236,6 +237,7 @@ impl Clone for ScreenCaptureConfig ScreenCaptureConfig { system_audio: bool, #[cfg(windows)] d3d_device: ::windows::Win32::Graphics::Direct3D11::ID3D11Device, #[cfg(target_os = "macos")] shareable_content: cidre::arc::R, - excluded_windows: Vec, + #[cfg(target_os = "macos")] excluded_windows: Vec, ) -> Result { cap_fail::fail!("ScreenCaptureSource::init"); @@ -405,6 +407,7 @@ impl ScreenCaptureConfig { d3d_device, #[cfg(target_os = "macos")] shareable_content: shareable_content.retained(), + #[cfg(target_os = "macos")] excluded_windows, }) } diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index c9420f5daa..75319ff2bb 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -345,6 +345,7 @@ pub struct ActorBuilder { mic_feed: Option>, camera_feed: Option>, custom_cursor: bool, + #[cfg(target_os = "macos")] excluded_windows: Vec, } @@ -357,6 +358,7 @@ impl ActorBuilder { mic_feed: None, camera_feed: None, custom_cursor: false, + #[cfg(target_os = "macos")] excluded_windows: Vec::new(), } } @@ -381,6 +383,7 @@ impl ActorBuilder { self } + #[cfg(target_os = "macos")] pub fn with_excluded_windows(mut self, excluded_windows: Vec) -> Self { self.excluded_windows = excluded_windows; self @@ -399,6 +402,7 @@ impl ActorBuilder { camera_feed: self.camera_feed, #[cfg(target_os = "macos")] shareable_content, + #[cfg(target_os = "macos")] excluded_windows: self.excluded_windows, }, self.custom_cursor, @@ -698,7 +702,8 @@ async fn create_segment_pipeline( d3d_device, #[cfg(target_os = "macos")] base_inputs.shareable_content, - &base_inputs.excluded_windows, + #[cfg(target_os = "macos")] + base_inputs.excluded_windows, ) .await .unwrap(); diff --git a/crates/scap-targets/src/lib.rs b/crates/scap-targets/src/lib.rs index 357dd23aed..be57e60736 100644 --- a/crates/scap-targets/src/lib.rs +++ b/crates/scap-targets/src/lib.rs @@ -139,10 +139,6 @@ impl Window { self.0.app_icon() } - pub fn bundle_identifier(&self) -> Option { - self.0.bundle_identifier() - } - pub fn raw_handle(&self) -> &WindowImpl { &self.0 } diff --git a/crates/scap-targets/src/platform/win.rs b/crates/scap-targets/src/platform/win.rs index 4d8521e9ab..817a230dd6 100644 --- a/crates/scap-targets/src/platform/win.rs +++ b/crates/scap-targets/src/platform/win.rs @@ -482,45 +482,6 @@ impl WindowImpl { } } - pub fn bundle_identifier(&self) -> Option { - // On Windows, use the executable name as the bundle identifier - // This is similar to the macOS bundle identifier concept - unsafe { - let mut process_id = 0u32; - GetWindowThreadProcessId(self.0, Some(&mut process_id)); - - if process_id == 0 { - return None; - } - - let process_handle = - OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_id).ok()?; - - let mut buffer = [0u16; 1024]; - let mut buffer_size = buffer.len() as u32; - - let result = QueryFullProcessImageNameW( - process_handle, - PROCESS_NAME_FORMAT::default(), - PWSTR(buffer.as_mut_ptr()), - &mut buffer_size, - ); - - let _ = CloseHandle(process_handle); - - if result.is_ok() && buffer_size > 0 { - let path_str = String::from_utf16_lossy(&buffer[..buffer_size as usize]); - - // Return the executable name (without extension) as the bundle identifier - std::path::Path::new(&path_str) - .file_stem() - .map(|stem| stem.to_string_lossy().into_owned()) - } else { - None - } - } - } - fn get_file_description(&self, file_path: &str) -> Option { unsafe { let wide_path: Vec = file_path.encode_utf16().chain(std::iter::once(0)).collect(); From b59cfa28025c46e409d9a59137f148acc55689ff Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 14 Oct 2025 02:07:02 +0800 Subject: [PATCH 6/9] fix build --- apps/desktop/src-tauri/src/window_exclusion.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/window_exclusion.rs b/apps/desktop/src-tauri/src/window_exclusion.rs index 08981aa207..915b405169 100644 --- a/apps/desktop/src-tauri/src/window_exclusion.rs +++ b/apps/desktop/src-tauri/src/window_exclusion.rs @@ -75,7 +75,7 @@ pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec { let window_title = window.name(); #[cfg(target_os = "macos")] - let bundle_identifier = window.bundle_identifier(); + let bundle_identifier = window.raw_handle().bundle_identifier(); #[cfg(not(target_os = "macos"))] let bundle_identifier = None; From b97a3ac645f3bb80ef60b7cd0115f83a07e65155 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 14 Oct 2025 02:10:11 +0800 Subject: [PATCH 7/9] more macos-only --- apps/desktop/src-tauri/src/recording.rs | 32 +++++++++++++++---------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 863c07fecf..23836fb7b9 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -476,18 +476,20 @@ pub async fn start_recording( recording_dir: recording_dir.clone(), }; - let window_exclusions = general_settings - .as_ref() - .map_or_else(general_settings::default_excluded_windows, |settings| { - settings.excluded_windows.clone() - }); - - let excluded_windows = - crate::window_exclusion::resolve_window_ids(&window_exclusions); + #[cfg(target_os = "macos")] + let excluded_windows = { + let window_exclusions = general_settings + .as_ref() + .map_or_else(general_settings::default_excluded_windows, |settings| { + settings.excluded_windows.clone() + }); + + crate::window_exclusion::resolve_window_ids(&window_exclusions) + }; let actor = match inputs.mode { RecordingMode::Studio => { - let mut builder = studio_recording::Actor::builder( + let builder = studio_recording::Actor::builder( recording_dir.clone(), inputs.capture_target.clone(), ) @@ -496,8 +498,10 @@ pub async fn start_recording( general_settings .map(|s| s.custom_cursor_capture) .unwrap_or_default(), - ) - .with_excluded_windows(excluded_windows.clone()); + ); + + #[cfg(target_os = "macos")] + let mut builder = builder.with_excluded_windows(excluded_windows.clone()); if let Some(camera_feed) = camera_feed { builder = builder.with_camera_feed(camera_feed); @@ -537,8 +541,10 @@ pub async fn start_recording( recording_dir.clone(), inputs.capture_target.clone(), ) - .with_system_audio(inputs.capture_system_audio) - .with_excluded_windows(excluded_windows.clone()); + .with_system_audio(inputs.capture_system_audio); + + #[cfg(target_os = "macos")] + let mut builder = builder.with_excluded_windows(excluded_windows.clone()); if let Some(mic_feed) = mic_feed { builder = builder.with_mic_feed(mic_feed); From 8e639c779f17a73168afe15b95fd35a46b66e108 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 14 Oct 2025 02:29:39 +0800 Subject: [PATCH 8/9] fix windows build --- apps/desktop/src-tauri/src/recording.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 23836fb7b9..c6d212540c 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -489,7 +489,7 @@ pub async fn start_recording( let actor = match inputs.mode { RecordingMode::Studio => { - let builder = studio_recording::Actor::builder( + let mut builder = studio_recording::Actor::builder( recording_dir.clone(), inputs.capture_target.clone(), ) @@ -501,7 +501,9 @@ pub async fn start_recording( ); #[cfg(target_os = "macos")] - let mut builder = builder.with_excluded_windows(excluded_windows.clone()); + { + builder = builder.with_excluded_windows(excluded_windows.clone()); + } if let Some(camera_feed) = camera_feed { builder = builder.with_camera_feed(camera_feed); @@ -544,7 +546,9 @@ pub async fn start_recording( .with_system_audio(inputs.capture_system_audio); #[cfg(target_os = "macos")] - let mut builder = builder.with_excluded_windows(excluded_windows.clone()); + { + builder = builder.with_excluded_windows(excluded_windows.clone()); + } if let Some(mic_feed) = mic_feed { builder = builder.with_mic_feed(mic_feed); From 93fd68f18b993321bee9294f4c508c7205cb264f Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 14 Oct 2025 02:43:56 +0800 Subject: [PATCH 9/9] fix --- apps/desktop/src-tauri/src/general_settings.rs | 11 ----------- apps/desktop/src-tauri/src/window_exclusion.rs | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 8d2595d5aa..e3f785d343 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -235,17 +235,6 @@ impl GeneralSettingsStore { store.set("general_settings", json!(self)); store.save().map_err(|e| e.to_string()) } - - pub fn is_window_excluded( - &self, - bundle_identifier: Option<&str>, - owner_name: Option<&str>, - window_title: Option<&str>, - ) -> bool { - self.excluded_windows - .iter() - .any(|entry| entry.matches(bundle_identifier, owner_name, window_title)) - } } pub fn init(app: &AppHandle) { diff --git a/apps/desktop/src-tauri/src/window_exclusion.rs b/apps/desktop/src-tauri/src/window_exclusion.rs index 915b405169..c0d6e72682 100644 --- a/apps/desktop/src-tauri/src/window_exclusion.rs +++ b/apps/desktop/src-tauri/src/window_exclusion.rs @@ -78,7 +78,7 @@ pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec { let bundle_identifier = window.raw_handle().bundle_identifier(); #[cfg(not(target_os = "macos"))] - let bundle_identifier = None; + let bundle_identifier = None::<&str>; exclusions .iter()