diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 083ec9bd4..ce1d28dd7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(pnpm typecheck:*)", "Bash(pnpm lint:*)", - "Bash(pnpm build:*)" + "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 771ae56a8..e3f785d34 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -1,3 +1,4 @@ +use crate::window_exclusion::WindowExclusion; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; @@ -39,6 +40,24 @@ impl MainWindowRecordingStartBehaviour { } } +const DEFAULT_EXCLUDED_WINDOW_TITLES: &[&str] = &[ + "Cap", + "Cap Settings", + "Cap Recording Controls", + "Cap Camera", +]; + +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 +118,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 +183,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(), } } } @@ -231,3 +253,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 77e6377fc..05289bd2d 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; @@ -1918,6 +1919,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, @@ -2017,7 +2020,8 @@ pub async fn run(recording_logging_handle: LoggingHandle) { .typ::() .typ::() .typ::() - .typ::(); + .typ::() + .typ::(); #[cfg(debug_assertions)] specta_builder @@ -2117,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 13790f2ac..c6d212540 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -45,7 +45,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::*, @@ -474,6 +476,17 @@ pub async fn start_recording( recording_dir: recording_dir.clone(), }; + #[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( @@ -487,6 +500,11 @@ pub async fn start_recording( .unwrap_or_default(), ); + #[cfg(target_os = "macos")] + { + builder = builder.with_excluded_windows(excluded_windows.clone()); + } + if let Some(camera_feed) = camera_feed { builder = builder.with_camera_feed(camera_feed); } @@ -527,6 +545,11 @@ pub async fn start_recording( ) .with_system_audio(inputs.capture_system_audio); + #[cfg(target_os = "macos")] + { + builder = builder.with_excluded_windows(excluded_windows.clone()); + } + if let Some(mic_feed) = mic_feed { builder = builder.with_mic_feed(mic_feed); } @@ -576,7 +599,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); } @@ -618,7 +641,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); } @@ -718,7 +741,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(); @@ -741,7 +764,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(); } @@ -805,7 +828,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/thumbnails/mod.rs b/apps/desktop/src-tauri/src/thumbnails/mod.rs index c4dbeb9da..66303520e 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, + #[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.raw_handle().bundle_identifier(); + + #[cfg(not(target_os = "macos"))] + let bundle_identifier = None::<&str>; + + 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 05d523776..5a5b11397 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -22,10 +22,11 @@ use tracing::{debug, error, warn}; use crate::{ App, ArcLock, RequestScreenCapturePrewarm, fake_window, - general_settings::{AppTheme, GeneralSettingsStore}, + general_settings::{self, AppTheme, GeneralSettingsStore}, permissions, recording_settings::RecordingTargetMode, target_select_overlay::WindowFocusManager, + window_exclusion::WindowExclusion, }; #[cfg(target_os = "macos")] @@ -43,7 +44,7 @@ pub enum CapWindowId { TargetSelectOverlay { display_id: DisplayId }, CaptureArea, Camera, - InProgressRecording, + RecordingControls, Upgrade, ModeSelect, Debug, @@ -59,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, @@ -101,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"), @@ -122,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(), @@ -152,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 @@ -250,6 +252,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 +263,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 +301,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 +316,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 +527,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 +549,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 +567,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 +624,9 @@ impl ShowCapWindow { let width = 250.0; let height = 40.0; + let title = CapWindowId::RecordingControls.title(); + let should_protect = should_protect_window(app, &title); + let window = self .window_builder(app, "/in-progress-recording") .maximized(false) @@ -613,7 +636,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 +657,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 +669,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(), @@ -758,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, } @@ -840,6 +866,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 853973d0e..789986f5a 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -6,11 +6,18 @@ 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, + createSignal, + 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"; @@ -18,14 +25,67 @@ import { Input } from "~/routes/editor/ui"; import { authStore, generalSettingsStore } from "~/store"; import { type AppTheme, + type CaptureWindow, commands, + events, type GeneralSettingsStore, type MainWindowRecordingStartBehaviour, type PostDeletionBehaviour, type PostStudioRecordingBehaviour, + type WindowExclusion, } from "~/utils/tauri"; +import IconLucidePlus from "~icons/lucide/plus"; +import IconLucideX from "~icons/lucide/x"; 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: CaptureWindow) => { + const parts = [window.owner_name]; + if (window.name && window.name !== window.owner_name) { + parts.push(window.name); + } + return parts.join(" • "); +}; + +const createDefaultGeneralSettings = (): GeneralSettingsStore => ({ + uploadIndividualFiles: false, + hideDockIcon: false, + autoCreateShareableLink: false, + enableNotifications: true, + enableNativeCameraPreview: false, + enableNewRecordingFlow: false, + autoZoomOnClicks: false, + custom_cursor_capture2: true, + enableNewUploader: false, + excludedWindows: [], +}); + +const deriveInitialSettings = ( + store: GeneralSettingsStore | null, +): GeneralSettingsStore => { + const defaults = createDefaultGeneralSettings(); + if (!store) return defaults; + + return { + ...defaults, + ...store, + }; +}; + export default function GeneralSettings() { const [store] = createResource(() => generalSettingsStore.get()); @@ -109,16 +169,21 @@ function AppearanceSection(props: { function Inner(props: { initialStore: GeneralSettingsStore | null }) { 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, { refetch: refetchWindows }] = createResource( + async () => { + // Fetch windows with a small delay to avoid blocking initial render + await new Promise((resolve) => setTimeout(resolve, 100)); + return commands.listCaptureWindows(); + }, + { + initialValue: [] as CaptureWindow[], }, ); @@ -133,6 +198,106 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { }; const ostype: OsType = type(); + const excludedWindows = createMemo(() => settings.excludedWindows ?? []); + + const matchesExclusion = ( + exclusion: WindowExclusion, + window: CaptureWindow, + ) => { + 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: CaptureWindow) => { + const bundle = window.bundle_identifier?.toLowerCase() ?? ""; + if (bundle.includes("so.cap.desktop")) { + return true; + } + return window.owner_name.toLowerCase().includes("cap"); + }; + + const isWindowAvailable = (window: CaptureWindow) => { + if (excludedWindows().some((entry) => matchesExclusion(entry, window))) { + return false; + } + if (ostype === "windows") { + return isManagedWindowsApp(window); + } + return true; + }; + + const availableWindows = createMemo(() => { + const data = windows() ?? []; + return data.filter(isWindowAvailable); + }); + + const refreshAvailableWindows = async (): Promise => { + try { + const refreshed = (await refetchWindows()) ?? windows() ?? []; + return refreshed.filter(isWindowAvailable); + } catch (error) { + console.error("Failed to refresh available windows", error); + return availableWindows(); + } + }; + + const applyExcludedWindows = async (windows: WindowExclusion[]) => { + setSettings("excludedWindows", windows); + try { + await generalSettingsStore.set({ excludedWindows: windows }); + await commands.refreshWindowContentProtection(); + if (ostype === "macos") { + await events.requestScreenCapturePrewarm.emit({ force: true }); + } + } 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: CaptureWindow) => { + 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; @@ -487,6 +652,17 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { )} + + { @@ -545,3 +721,172 @@ function ServerURLSetting(props: { ); } + +function ExcludedWindowsCard(props: { + excludedWindows: WindowExclusion[]; + availableWindows: CaptureWindow[]; + onRequestAvailableWindows: () => Promise; + onRemove: (index: number) => Promise; + onAdd: (window: CaptureWindow) => Promise; + onReset: () => Promise; + isLoading: boolean; + isWindows: boolean; +}) { + const hasExclusions = () => props.excludedWindows.length > 0; + const canAdd = () => !props.isLoading; + + const handleAddClick = async (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (!canAdd()) return; + + // Use available windows if we have them, otherwise fetch + let windows = props.availableWindows; + + // Only refresh if we don't have any windows cached + if (!windows.length) { + try { + windows = await props.onRequestAvailableWindows(); + } catch (error) { + console.error("Failed to fetch windows:", error); + return; + } + } + + if (!windows.length) { + console.log("No available windows to exclude"); + return; + } + + try { + const items = await Promise.all( + windows.map((window) => + MenuItem.new({ + text: getWindowOptionLabel(window), + action: () => { + void props.onAdd(window); + }, + }), + ), + ); + + const menu = await Menu.new({ items }); + + // Save scroll position before popup + const scrollPos = window.scrollY; + + await menu.popup(); + await menu.close(); + + // Restore scroll position after menu closes + requestAnimationFrame(() => { + window.scrollTo(0, scrollPos); + }); + } catch (error) { + console.error("Error showing window menu:", error); + } + }; + + return ( +
+
+
+

Excluded Windows

+

+ Choose which windows Cap hides from your recordings. +

+ +

+ Note: Only Cap + related windows can be excluded on Windows due to technical + limitations. +

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

+ } + > +
+ + {(entry, index) => ( +
+
+ + {getExclusionPrimaryLabel(entry)} + + + {(label) => ( + + {label()} + + )} + +
+ +
+ )} +
+
+
+
+
+ ); +} + +function ExcludedWindowsSkeleton() { + const chipWidths = ["w-32", "w-28", "w-36"] as const; + + return ( +