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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/rsnap/src/app/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ impl App {
OverlayConfig {
hud_anchor: HudAnchor::Cursor,
show_alt_hint_keycap: self.settings.show_alt_hint_keycap,
selection_particles: self.settings.selection_particles,
selection_flow_enabled: self.settings.selection_flow_enabled,
selection_flow_stroke_width_px: self
.settings
.selection_flow_stroke_width_px
Expand Down
137 changes: 119 additions & 18 deletions apps/rsnap/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ impl LoupeSampleSize {
}

#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
Comment thread
yvette-carlisle marked this conversation as resolved.
pub(crate) struct AppSettings {
#[serde(default)]
pub show_alt_hint_keycap: bool,
Expand All @@ -63,8 +64,8 @@ pub(crate) struct AppSettings {
pub hud_tint_hue: f32,
#[serde(default)]
pub alt_activation: AltActivationMode,
#[serde(default = "default_selection_particles")]
pub selection_particles: bool,
#[serde(default = "default_selection_flow_enabled")]
pub selection_flow_enabled: bool,
#[serde(default = "default_selection_flow_stroke_width_px")]
pub selection_flow_stroke_width_px: f32,
pub log_filter: Option<String>,
Expand All @@ -89,13 +90,31 @@ impl AppSettings {
let Some(path) = Self::path() else {
return Self::default();
};
let Ok(bytes) = fs::read(&path) else {

Self::load_from_path(&path)
}

#[must_use]
fn load_from_path(path: &Path) -> Self {
let Ok(bytes) = fs::read(path) else {
return Self::default();
};
let Ok(contents) = std::str::from_utf8(&bytes) else {
quarantine_invalid_settings_file(path, "Settings file is not valid UTF-8");

return Self::default();
};
let mut settings: Self = toml::from_str(contents).unwrap_or_default();
let mut settings: Self = match toml::from_str(contents) {
Ok(settings) => settings,
Err(err) => {
quarantine_invalid_settings_file(
path,
&format!("Failed to parse settings TOML: {err}"),
);

return Self::default();
},
};

settings.capture_hotkey = sanitize_capture_hotkey(&settings.capture_hotkey)
.unwrap_or_else(default_capture_hotkey);
Expand Down Expand Up @@ -156,7 +175,7 @@ impl Default for AppSettings {
hud_tint: default_hud_tint(),
hud_tint_hue: default_hud_tint_hue(),
alt_activation: AltActivationMode::default(),
selection_particles: default_selection_particles(),
selection_flow_enabled: default_selection_flow_enabled(),
selection_flow_stroke_width_px: default_selection_flow_stroke_width_px(),
log_filter: None,
output_dir: default_output_dir(),
Expand Down Expand Up @@ -203,7 +222,7 @@ fn default_hud_tint_hue() -> f32 {
215.0 / 360.0
}

fn default_selection_particles() -> bool {
fn default_selection_flow_enabled() -> bool {
true
}

Expand Down Expand Up @@ -304,9 +323,51 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
Ok(())
}

fn quarantine_invalid_settings_file(path: &Path, message: &str) {
let backup_path = invalid_settings_backup_path(path);

match fs::rename(path, &backup_path) {
Ok(()) => eprintln!(
"{message}. Moved invalid settings file from {:?} to {:?}.",
path, backup_path
),
Err(err) => eprintln!(
"{message}. Failed to move invalid settings file {:?} to {:?}: {err}",
path, backup_path
),
}
}

fn invalid_settings_backup_path(path: &Path) -> PathBuf {
let parent = path.parent().map(Path::to_path_buf).unwrap_or_default();
let stem = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or("settings");
let ext = path.extension().and_then(|ext| ext.to_str());

for suffix in 0_u32.. {
let candidate_name = match (ext, suffix) {
(Some(ext), 0) => format!("{stem}.invalid.{ext}"),
(Some(ext), suffix) => format!("{stem}.invalid-{suffix}.{ext}"),
(None, 0) => format!("{stem}.invalid"),
(None, suffix) => format!("{stem}.invalid-{suffix}"),
};
let candidate = parent.join(candidate_name);

if !candidate.exists() {
return candidate;
}
}

unreachable!("u32 suffix space exhausted for invalid settings backups")
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::{
env, fs,
path::PathBuf,
process,
time::{SystemTime, UNIX_EPOCH},
};

use crate::settings::{AltActivationMode, AppSettings, LoupeSampleSize};
use rsnap_overlay::{OutputNaming, ThemeMode, ToolbarPlacement, WindowCaptureAlphaMode};
Expand All @@ -331,7 +392,7 @@ mod tests {
hud_tint = 0.25
hud_tint_hue = 0.4
alt_activation = "toggle"
selection_particles = true
selection_flow_enabled = false
selection_flow_stroke_width_px = 2.4
output_dir = "/tmp/rsnap-output"
output_filename_prefix = "shot"
Expand All @@ -344,7 +405,7 @@ mod tests {
let settings: AppSettings = toml::from_str(input).unwrap();

assert_eq!(settings.alt_activation, AltActivationMode::Toggle);
assert!(settings.selection_particles);
assert!(!settings.selection_flow_enabled);
assert_eq!(settings.selection_flow_stroke_width_px, 2.4);
assert_eq!(settings.output_dir, PathBuf::from("/tmp/rsnap-output"));
assert_eq!(settings.output_filename_prefix, "shot");
Expand All @@ -356,23 +417,34 @@ mod tests {
}

#[test]
fn toml_ignores_legacy_tray_icon_keys() {
let baseline: AppSettings = toml::from_str("").unwrap();
let tray_icon_inverted: AppSettings = toml::from_str("tray_icon_inverted = true").unwrap();
let tray_icon_filled: AppSettings = toml::from_str("tray_icon_filled = true").unwrap();
fn toml_rejects_legacy_tray_icon_keys() {
assert!(toml::from_str::<AppSettings>("tray_icon_inverted = true").is_err());
assert!(toml::from_str::<AppSettings>("tray_icon_filled = true").is_err());
}

assert_eq!(tray_icon_inverted, baseline);
assert_eq!(tray_icon_filled, baseline);
#[test]
fn selection_flow_defaults_to_enabled_when_missing() {
let settings: AppSettings = toml::from_str("").unwrap();

assert!(settings.selection_flow_enabled);
}

#[test]
fn window_capture_alpha_mode_preserve_alias_maps_to_background() {
fn selection_flow_rejects_legacy_key() {
let input = r#"
selection_particles = false
"#;

assert!(toml::from_str::<AppSettings>(input).is_err());
}

#[test]
fn window_capture_alpha_mode_rejects_legacy_preserve_value() {
let input = r#"
window_capture_alpha_mode = "preserve"
"#;
let settings: AppSettings = toml::from_str(input).unwrap();

assert_eq!(settings.window_capture_alpha_mode, WindowCaptureAlphaMode::Background);
assert!(toml::from_str::<AppSettings>(input).is_err());
}

#[test]
Expand All @@ -394,4 +466,33 @@ mod tests {

assert_eq!(sanitized, "rsnap__demo");
}

#[test]
fn load_quarantines_invalid_settings_instead_of_silently_dropping_them() {
let root = env::temp_dir().join(format!(
"rsnap-settings-test-{}-{}",
process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(),
));

fs::create_dir_all(&root).unwrap();

let settings_path = root.join("settings.toml");
let original = r#"
show_alt_hint_keycap = false
selection_particles = false
"#;

fs::write(&settings_path, original).unwrap();

let settings = AppSettings::load_from_path(&settings_path);
let backup_path = root.join("settings.invalid.toml");

assert_eq!(settings, AppSettings::default());
assert!(!settings_path.exists());
assert_eq!(fs::read_to_string(&backup_path).unwrap(), original);

fs::remove_file(&backup_path).unwrap();
fs::remove_dir(&root).unwrap();
}
}
2 changes: 1 addition & 1 deletion apps/rsnap/src/settings_window/bench_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ fn settings_for_scenario(scenario: SettingsUiBenchScenario) -> AppSettings {
settings.hud_tint = 0.68;
settings.hud_tint_hue = 0.88;
settings.alt_activation = AltActivationMode::Toggle;
settings.selection_particles = true;
settings.selection_flow_enabled = true;
settings.selection_flow_stroke_width_px = 6.4;
settings.log_filter = Some(String::from("rsnap=trace,rsnap_overlay=trace"));
settings.output_dir =
Expand Down
4 changes: 2 additions & 2 deletions apps/rsnap/src/settings_window/sections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,12 +557,12 @@ fn render_overlay_section(combo_width: f32, ui: &mut Ui, settings: &mut AppSetti

changed |= ui.checkbox(&mut settings.show_alt_hint_keycap, "Show Alt hint in HUD").changed();
changed |= ui.checkbox(&mut settings.hud_glass_enabled, "Glass HUD").changed();
changed |= ui.checkbox(&mut settings.selection_particles, "Selection particles").changed();
changed |= ui.checkbox(&mut settings.selection_flow_enabled, "Selection flow").changed();
changed |= overlay_range_slider_row(
ui,
"Flow thickness",
&mut settings.selection_flow_stroke_width_px,
settings.selection_particles,
settings.selection_flow_enabled,
);

ui.add_space(SETTINGS_SECTION_GAP);
Expand Down
54 changes: 49 additions & 5 deletions packages/rsnap-overlay/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use std::collections::HashMap;
#[cfg(target_os = "macos")]
use std::ffi::{CString, c_char, c_void};
#[cfg(not(target_os = "macos"))]
use std::process;
#[cfg(target_os = "macos")]
use std::ptr;
Expand Down Expand Up @@ -288,6 +287,8 @@ pub struct XcapCaptureBackend {
window_cache: Option<Arc<WindowListSnapshot>>,
window_cache_ttl: Duration,
#[cfg(target_os = "macos")]
self_capture_exception_window_ids: Vec<u32>,
#[cfg(target_os = "macos")]
live_frame_stream: MacLiveFrameStream,
#[cfg(target_os = "macos")]
last_region_capture: HashMap<u32, MacosRegionCaptureState>,
Expand All @@ -313,6 +314,8 @@ impl XcapCaptureBackend {
window_cache: None,
window_cache_ttl: Duration::from_millis(250),
#[cfg(target_os = "macos")]
self_capture_exception_window_ids: self_capture_exception_window_ids.clone(),
#[cfg(target_os = "macos")]
live_frame_stream: MacLiveFrameStream::with_self_capture_exception_window_ids(
self_capture_exception_window_ids,
),
Expand Down Expand Up @@ -626,7 +629,11 @@ impl XcapCaptureBackend {
}

fn refresh_window_cache_impl(&mut self) -> Result<Arc<WindowListSnapshot>> {
let windows = collect_window_geometries().wrap_err("failed to refresh window cache")?;
let windows = collect_window_geometries(
#[cfg(target_os = "macos")]
&self.self_capture_exception_window_ids,
)
.wrap_err("failed to refresh window cache")?;
let snapshot = Arc::new(WindowListSnapshot {
captured_at: Instant::now(),
windows: Arc::new(windows),
Expand Down Expand Up @@ -1221,7 +1228,7 @@ fn capture_screenshot_cg_image(rect: CGRect) -> Result<Retained<CGImage>> {
}

#[cfg(target_os = "macos")]
fn collect_window_geometries() -> Result<Vec<WindowRect>> {
fn collect_window_geometries(self_capture_exception_window_ids: &[u32]) -> Result<Vec<WindowRect>> {
let window_list_ref = unsafe {
CGWindowListCopyWindowInfo(
KCG_WINDOW_LIST_OPTION_ON_SCREEN_ONLY | KCG_WINDOW_LIST_OPTION_EXCLUDE_DESKTOP,
Expand Down Expand Up @@ -1250,7 +1257,9 @@ fn collect_window_geometries() -> Result<Vec<WindowRect>> {
continue;
};

if let Some(window_geometry) = window_geometry_from_dictionary(window_dict) {
if let Some(window_geometry) =
window_geometry_from_dictionary(window_dict, self_capture_exception_window_ids)
{
windows.push(window_geometry);
}

Expand All @@ -1261,9 +1270,13 @@ fn collect_window_geometries() -> Result<Vec<WindowRect>> {
}

#[cfg(target_os = "macos")]
fn window_geometry_from_dictionary(window_dictionary: CFDictionaryRef) -> Option<WindowRect> {
fn window_geometry_from_dictionary(
window_dictionary: CFDictionaryRef,
self_capture_exception_window_ids: &[u32],
) -> Option<WindowRect> {
let is_on_screen = cf_bool_value(window_dictionary, "kCGWindowIsOnscreen")?;
let window_id = cf_number_to_u32(window_dictionary, "kCGWindowNumber");
let owner_pid = cf_number_to_u32(window_dictionary, "kCGWindowOwnerPID");
let layer = cf_number_to_u64(window_dictionary, "kCGWindowLayer")?;
let bounds_dict = cf_dictionary_value(window_dictionary, "kCGWindowBounds")?;
let x = cf_number_to_i64(bounds_dict, "X")?;
Expand All @@ -1274,10 +1287,27 @@ fn window_geometry_from_dictionary(window_dictionary: CFDictionaryRef) -> Option
if !is_on_screen || layer > KCG_WINDOW_LAYER_MAX_FOR_TARGETING || width <= 0 || height <= 0 {
return None;
}
if should_exclude_current_process_window(
window_id,
owner_pid,
self_capture_exception_window_ids,
) {
return None;
}

Some(WindowRect { window_id, x, y, width, height })
}

#[cfg(target_os = "macos")]
fn should_exclude_current_process_window(
window_id: Option<u32>,
owner_pid: Option<u32>,
self_capture_exception_window_ids: &[u32],
) -> bool {
owner_pid.is_some_and(|pid| pid == process::id())
&& !window_id.is_some_and(|id| self_capture_exception_window_ids.contains(&id))
}

#[cfg(target_os = "macos")]
fn cf_dictionary_value(dictionary: CFDictionaryRef, key: &str) -> Option<CFTypeRef> {
let key_ref = cf_string_ref_for_key(key)?;
Expand Down Expand Up @@ -1498,6 +1528,9 @@ fn xcap_find_monitor(monitor: MonitorRect) -> Result<xcap::Monitor> {

#[cfg(test)]
mod tests {
#[cfg(target_os = "macos")]
use std::process;

use image::RgbaImage;
#[cfg(target_os = "macos")]
use objc2_foundation::NSOperatingSystemVersion;
Expand Down Expand Up @@ -1590,6 +1623,17 @@ mod tests {
));
}

#[cfg(target_os = "macos")]
#[test]
fn current_process_windows_are_excluded_from_window_targeting_unless_excepted() {
let self_pid = process::id();

assert!(backend::should_exclude_current_process_window(Some(41), Some(self_pid), &[]));
assert!(!backend::should_exclude_current_process_window(Some(41), Some(self_pid), &[41],));
assert!(!backend::should_exclude_current_process_window(Some(41), Some(self_pid + 1), &[]));
assert!(!backend::should_exclude_current_process_window(None, None, &[]));
}

#[test]
fn normalize_capture_image_extent_pads_inward_rounded_edges_with_border_pixels() {
let image = RgbaImage::from_vec(
Expand Down
Loading
Loading