Skip to content

Commit af2c83d

Browse files
feat: Implement new Window excluder system (#1179)
* feat: Basic window excluding UI * feat: Cleanup bits * fix: bundle_identifier on Windows * feat: Implement CR suggestions + move definition to desktop * cleanup * fix build * more macos-only * fix windows build * fix --------- Co-authored-by: Brendan Allan <brendonovich@outlook.com>
1 parent 175b0d7 commit af2c83d

File tree

17 files changed

+713
-51
lines changed

17 files changed

+713
-51
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"allow": [
44
"Bash(pnpm typecheck:*)",
55
"Bash(pnpm lint:*)",
6-
"Bash(pnpm build:*)"
6+
"Bash(pnpm build:*)",
7+
"Bash(cargo check:*)"
78
],
89
"deny": [],
910
"ask": []

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::window_exclusion::WindowExclusion;
12
use serde::{Deserialize, Serialize};
23
use serde_json::json;
34
use specta::Type;
@@ -39,6 +40,24 @@ impl MainWindowRecordingStartBehaviour {
3940
}
4041
}
4142

43+
const DEFAULT_EXCLUDED_WINDOW_TITLES: &[&str] = &[
44+
"Cap",
45+
"Cap Settings",
46+
"Cap Recording Controls",
47+
"Cap Camera",
48+
];
49+
50+
pub fn default_excluded_windows() -> Vec<WindowExclusion> {
51+
DEFAULT_EXCLUDED_WINDOW_TITLES
52+
.iter()
53+
.map(|title| WindowExclusion {
54+
bundle_identifier: None,
55+
owner_name: None,
56+
window_title: Some((*title).to_string()),
57+
})
58+
.collect()
59+
}
60+
4261
// When adding fields here, #[serde(default)] defines the value to use for existing configurations,
4362
// and `Default::default` defines the value to use for new configurations.
4463
// Things that affect the user experience should only be enabled by default for new configurations.
@@ -99,6 +118,8 @@ pub struct GeneralSettingsStore {
99118
pub post_deletion_behaviour: PostDeletionBehaviour,
100119
#[serde(default = "default_enable_new_uploader", skip_serializing_if = "no")]
101120
pub enable_new_uploader: bool,
121+
#[serde(default = "default_excluded_windows")]
122+
pub excluded_windows: Vec<WindowExclusion>,
102123
}
103124

104125
fn default_enable_native_camera_preview() -> bool {
@@ -162,6 +183,7 @@ impl Default for GeneralSettingsStore {
162183
enable_new_recording_flow: default_enable_new_recording_flow(),
163184
post_deletion_behaviour: PostDeletionBehaviour::DoNothing,
164185
enable_new_uploader: default_enable_new_uploader(),
186+
excluded_windows: default_excluded_windows(),
165187
}
166188
}
167189
}
@@ -231,3 +253,9 @@ pub fn init(app: &AppHandle) {
231253

232254
println!("GeneralSettingsState managed");
233255
}
256+
257+
#[tauri::command]
258+
#[specta::specta]
259+
pub fn get_default_excluded_windows() -> Vec<WindowExclusion> {
260+
default_excluded_windows()
261+
}

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ mod tray;
2525
mod upload;
2626
mod upload_legacy;
2727
mod web_api;
28+
mod window_exclusion;
2829
mod windows;
2930

3031
use audio::AppSounds;
@@ -1918,6 +1919,8 @@ pub async fn run(recording_logging_handle: LoggingHandle) {
19181919
recording::list_capture_displays,
19191920
recording::list_displays_with_thumbnails,
19201921
recording::list_windows_with_thumbnails,
1922+
windows::refresh_window_content_protection,
1923+
general_settings::get_default_excluded_windows,
19211924
take_screenshot,
19221925
list_audio_devices,
19231926
close_recordings_overlay_window,
@@ -2017,7 +2020,8 @@ pub async fn run(recording_logging_handle: LoggingHandle) {
20172020
.typ::<hotkeys::HotkeysStore>()
20182021
.typ::<general_settings::GeneralSettingsStore>()
20192022
.typ::<recording_settings::RecordingSettingsStore>()
2020-
.typ::<cap_flags::Flags>();
2023+
.typ::<cap_flags::Flags>()
2024+
.typ::<crate::window_exclusion::WindowExclusion>();
20212025

20222026
#[cfg(debug_assertions)]
20232027
specta_builder
@@ -2117,7 +2121,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) {
21172121
CapWindowId::CaptureArea.label().as_str(),
21182122
CapWindowId::Camera.label().as_str(),
21192123
CapWindowId::RecordingsOverlay.label().as_str(),
2120-
CapWindowId::InProgressRecording.label().as_str(),
2124+
CapWindowId::RecordingControls.label().as_str(),
21212125
CapWindowId::Upgrade.label().as_str(),
21222126
])
21232127
.map_label(|label| match label {

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

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ use crate::{
4545
audio::AppSounds,
4646
auth::AuthStore,
4747
create_screenshot,
48-
general_settings::{GeneralSettingsStore, PostDeletionBehaviour, PostStudioRecordingBehaviour},
48+
general_settings::{
49+
self, GeneralSettingsStore, PostDeletionBehaviour, PostStudioRecordingBehaviour,
50+
},
4951
open_external_link,
5052
presets::PresetsStore,
5153
thumbnails::*,
@@ -474,6 +476,17 @@ pub async fn start_recording(
474476
recording_dir: recording_dir.clone(),
475477
};
476478

479+
#[cfg(target_os = "macos")]
480+
let excluded_windows = {
481+
let window_exclusions = general_settings
482+
.as_ref()
483+
.map_or_else(general_settings::default_excluded_windows, |settings| {
484+
settings.excluded_windows.clone()
485+
});
486+
487+
crate::window_exclusion::resolve_window_ids(&window_exclusions)
488+
};
489+
477490
let actor = match inputs.mode {
478491
RecordingMode::Studio => {
479492
let mut builder = studio_recording::Actor::builder(
@@ -487,6 +500,11 @@ pub async fn start_recording(
487500
.unwrap_or_default(),
488501
);
489502

503+
#[cfg(target_os = "macos")]
504+
{
505+
builder = builder.with_excluded_windows(excluded_windows.clone());
506+
}
507+
490508
if let Some(camera_feed) = camera_feed {
491509
builder = builder.with_camera_feed(camera_feed);
492510
}
@@ -527,6 +545,11 @@ pub async fn start_recording(
527545
)
528546
.with_system_audio(inputs.capture_system_audio);
529547

548+
#[cfg(target_os = "macos")]
549+
{
550+
builder = builder.with_excluded_windows(excluded_windows.clone());
551+
}
552+
530553
if let Some(mic_feed) = mic_feed {
531554
builder = builder.with_mic_feed(mic_feed);
532555
}
@@ -576,7 +599,7 @@ pub async fn start_recording(
576599
)
577600
.kind(tauri_plugin_dialog::MessageDialogKind::Error);
578601

579-
if let Some(window) = CapWindowId::InProgressRecording.get(&app) {
602+
if let Some(window) = CapWindowId::RecordingControls.get(&app) {
580603
dialog = dialog.parent(&window);
581604
}
582605

@@ -618,7 +641,7 @@ pub async fn start_recording(
618641
)
619642
.kind(tauri_plugin_dialog::MessageDialogKind::Error);
620643

621-
if let Some(window) = CapWindowId::InProgressRecording.get(&app) {
644+
if let Some(window) = CapWindowId::RecordingControls.get(&app) {
622645
dialog = dialog.parent(&window);
623646
}
624647

@@ -718,7 +741,7 @@ pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> R
718741
}
719742
};
720743

721-
if let Some((recording, recording_dir, video_id)) = recording_data {
744+
if let Some((_, recording_dir, video_id)) = recording_data {
722745
CurrentRecordingChanged.emit(&app).ok();
723746
RecordingStopped {}.emit(&app).ok();
724747

@@ -741,7 +764,7 @@ pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> R
741764
.flatten()
742765
.unwrap_or_default();
743766

744-
if let Some(window) = CapWindowId::InProgressRecording.get(&app) {
767+
if let Some(window) = CapWindowId::RecordingControls.get(&app) {
745768
let _ = window.close();
746769
}
747770

@@ -805,7 +828,7 @@ async fn handle_recording_end(
805828

806829
let _ = app.recording_logging_handle.reload(None);
807830

808-
if let Some(window) = CapWindowId::InProgressRecording.get(&handle) {
831+
if let Some(window) = CapWindowId::RecordingControls.get(&handle) {
809832
let _ = window.close();
810833
}
811834

apps/desktop/src-tauri/src/thumbnails/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pub struct CaptureWindowWithThumbnail {
3333
pub refresh_rate: u32,
3434
pub thumbnail: Option<String>,
3535
pub app_icon: Option<String>,
36+
pub bundle_identifier: Option<String>,
3637
}
3738

3839
pub fn normalize_thumbnail_dimensions(image: &image::RgbaImage) -> image::RgbaImage {
@@ -140,6 +141,7 @@ pub async fn collect_windows_with_thumbnails() -> Result<Vec<CaptureWindowWithTh
140141
refresh_rate: capture_window.refresh_rate,
141142
thumbnail,
142143
app_icon,
144+
bundle_identifier: capture_window.bundle_identifier,
143145
});
144146
}
145147

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
use scap_targets::Window;
2+
use scap_targets::WindowId;
3+
use serde::{Deserialize, Serialize};
4+
use specta::Type;
5+
6+
#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)]
7+
#[serde(rename_all = "camelCase")]
8+
pub struct WindowExclusion {
9+
#[serde(default, skip_serializing_if = "Option::is_none")]
10+
pub bundle_identifier: Option<String>,
11+
#[serde(default, skip_serializing_if = "Option::is_none")]
12+
pub owner_name: Option<String>,
13+
#[serde(default, skip_serializing_if = "Option::is_none")]
14+
pub window_title: Option<String>,
15+
}
16+
17+
impl WindowExclusion {
18+
pub fn matches(
19+
&self,
20+
bundle_identifier: Option<&str>,
21+
owner_name: Option<&str>,
22+
window_title: Option<&str>,
23+
) -> bool {
24+
if let Some(identifier) = self.bundle_identifier.as_deref() {
25+
if bundle_identifier
26+
.map(|candidate| candidate == identifier)
27+
.unwrap_or(false)
28+
{
29+
return true;
30+
}
31+
}
32+
33+
if let Some(expected_owner) = self.owner_name.as_deref() {
34+
let owner_matches = owner_name
35+
.map(|candidate| candidate == expected_owner)
36+
.unwrap_or(false);
37+
38+
if self.window_title.is_some() {
39+
return owner_matches
40+
&& self
41+
.window_title
42+
.as_deref()
43+
.map(|expected_title| {
44+
window_title
45+
.map(|candidate| candidate == expected_title)
46+
.unwrap_or(false)
47+
})
48+
.unwrap_or(false);
49+
}
50+
51+
if owner_matches {
52+
return true;
53+
}
54+
}
55+
56+
if let Some(expected_title) = self.window_title.as_deref() {
57+
return window_title
58+
.map(|candidate| candidate == expected_title)
59+
.unwrap_or(false);
60+
}
61+
62+
false
63+
}
64+
}
65+
66+
pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec<WindowId> {
67+
if exclusions.is_empty() {
68+
return Vec::new();
69+
}
70+
71+
Window::list()
72+
.into_iter()
73+
.filter_map(|window| {
74+
let owner_name = window.owner_name();
75+
let window_title = window.name();
76+
77+
#[cfg(target_os = "macos")]
78+
let bundle_identifier = window.raw_handle().bundle_identifier();
79+
80+
#[cfg(not(target_os = "macos"))]
81+
let bundle_identifier = None::<&str>;
82+
83+
exclusions
84+
.iter()
85+
.find(|entry| {
86+
entry.matches(
87+
bundle_identifier.as_deref(),
88+
owner_name.as_deref(),
89+
window_title.as_deref(),
90+
)
91+
})
92+
.map(|_| window.id())
93+
})
94+
.collect()
95+
}

0 commit comments

Comments
 (0)