From 83d2d9f2db6818257ac94e85951a8d67614745e5 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Mon, 7 Sep 2026 13:20:36 +0800 Subject: [PATCH] hide scene window when screenshot utility is active --- Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/ui/scene/mod.rs | 56 +++-- .../src/ui/scene/screenshot_picker/macos.rs | 191 ++++++++++++++++++ .../src/ui/scene/screenshot_picker/mod.rs | 102 ++++++++++ 5 files changed, 330 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/ui/scene/screenshot_picker/macos.rs create mode 100644 src-tauri/src/ui/scene/screenshot_picker/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 8857390..5b1de2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,7 @@ dependencies = [ "getrandom 0.4.3", "image", "lazy_static", + "libc", "objc2", "objc2-app-kit", "objc2-foundation", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 598ca22..4bdc3a3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -59,6 +59,7 @@ windows = { version = "0.58", features = [ ] } [target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" objc2 = "0.6.3" objc2-app-kit = "0.3.2" objc2-foundation = "0.3.2" diff --git a/src-tauri/src/ui/scene/mod.rs b/src-tauri/src/ui/scene/mod.rs index b8fdb97..aadc9f0 100644 --- a/src-tauri/src/ui/scene/mod.rs +++ b/src-tauri/src/ui/scene/mod.rs @@ -1,3 +1,5 @@ +mod screenshot_picker; + use std::sync::RwLock; use serde::Deserialize; @@ -162,11 +164,7 @@ pub fn overlay_fullscreen( Ok(()) } -fn open_window(app_handle: &AppHandle) -> Result { - if let Some(window) = app_handle.get_webview_window(WINDOW_LABEL) { - return Ok(window); - }; - +fn open_window(app_handle: &AppHandle, visible: bool) -> Result<(), String> { let builder = tauri::WebviewWindowBuilder::new( app_handle, WINDOW_LABEL, @@ -194,7 +192,9 @@ fn open_window(app_handle: &AppHandle) -> Result { let configure = || -> Result<(), tauri::Error> { overlay_fullscreen(app_handle, &window)?; window.set_ignore_cursor_events(true)?; - window.show()?; + if visible { + window.show()?; + } Ok(()) }; if let Err(error) = configure() { @@ -204,12 +204,12 @@ fn open_window(app_handle: &AppHandle) -> Result { apply_macos_decoration_window_policy(app_handle, WINDOW_LABEL.to_string()); track_scene_hitboxes(app_handle.clone(), window.clone()); - Ok(window) + Ok(()) } /// Serializes native create/destroy requests, including destruction acknowledgement. #[derive(Default)] -struct SceneWindow(tokio::sync::Mutex<()>); +struct SceneWindow(tokio::sync::Mutex); pub(crate) fn is_initialized(handle: &AppHandle) -> bool { handle.try_state::().is_some() @@ -220,18 +220,11 @@ pub(crate) async fn reconcile_window(handle: &AppHandle, open: bool) -> Result<( let state = handle .try_state::() .ok_or("scene UI has not started")?; - let _guard = state.0.lock().await; - if open { - if handle.get_webview_window(WINDOW_LABEL).is_none() { - handle - .state::() - .0 - .write() - .map_err(|error| error.to_string())? - .clear(); - open_window(&handle)?; - } - } else if let Some(window) = handle.get_webview_window(WINDOW_LABEL) { + let mut screenshot_picker = state.0.lock().await; + if !open { + let Some(window) = handle.get_webview_window(WINDOW_LABEL) else { + return Ok(()); + }; let (sender, receiver) = tokio::sync::oneshot::channel(); let sender = std::sync::Mutex::new(Some(sender)); window.on_window_event(move |event| { @@ -249,13 +242,34 @@ pub(crate) async fn reconcile_window(handle: &AppHandle, open: bool) -> Result<( .write() .map_err(|error| error.to_string())? .clear(); + return Ok(()); + } + + // Share serialization with create/destroy: picker dismissal must never reopen + // a scene which the puppet visibility policy has closed. + let suppressed = screenshot_picker.suppressed().await; + let Some(window) = handle.get_webview_window(WINDOW_LABEL) else { + handle + .state::() + .0 + .write() + .map_err(|error| error.to_string())? + .clear(); + return open_window(handle, !suppressed); + }; + if window.is_visible().map_err(|error| error.to_string())? == suppressed { + if suppressed { + window.hide() + } else { + window.show() + } + .map_err(|error| error.to_string())?; } Ok(()) } pub fn init(app_handle: &AppHandle) { app_handle.manage(SceneHitboxes::default()); - // The existing puppet tick opens the scene only after publishing its snapshot. app_handle.manage(SceneWindow::default()); } diff --git a/src-tauri/src/ui/scene/screenshot_picker/macos.rs b/src-tauri/src/ui/scene/screenshot_picker/macos.rs new file mode 100644 index 0000000..e1b2fb0 --- /dev/null +++ b/src-tauri/src/ui/scene/screenshot_picker/macos.rs @@ -0,0 +1,191 @@ +//! Compatibility shim for macOS Screenshot's screencapture toolbar process. +//! `-U` is documented by screencapture(1); `keyboard.interactive` is an +//! implementation detail observed in the system keyboard shortcut invocation. +//! This follows process lifetime, including any recording started by the toolbar. +use std::{io, mem::size_of}; + +pub(super) const ENABLED: bool = true; + +// sys/proc_info.h, included by libproc.h; not currently exposed by libc. +const PROC_ALL_PIDS: u32 = 1; + +pub(super) fn is_active() -> io::Result { + // proc_listpids returns bytes, not a PID count. Retry a full buffer because + // the process table can grow between the sizing and filling calls. + let bytes = unsafe { libc::proc_listpids(PROC_ALL_PIDS, 0, std::ptr::null_mut(), 0) }; + if bytes <= 0 { + return Err(io::Error::last_os_error()); + } + let mut pids = vec![0i32; bytes as usize / size_of::() + 64]; + loop { + let capacity = i32::try_from(pids.len() * size_of::()) + .map_err(|_| io::Error::other("process table too large"))?; + // SAFETY: pids is an aligned, initialized buffer of capacity bytes. + let bytes = + unsafe { libc::proc_listpids(PROC_ALL_PIDS, 0, pids.as_mut_ptr().cast(), capacity) }; + if bytes < 0 { + return Err(io::Error::last_os_error()); + } + if bytes < capacity { + pids.truncate(bytes as usize / size_of::()); + break; + } + pids.resize(pids.len() * 2, 0); + } + + for pid in pids.into_iter().filter(|pid| *pid > 0) { + let mut path = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + // SAFETY: path is writable for the advertised size. Processes which + // exit or are inaccessible between these calls are retried next scan. + let length = + unsafe { libc::proc_pidpath(pid, path.as_mut_ptr().cast(), path.len() as u32) }; + if length <= 0 || path.split(|byte| *byte == 0).next() != Some(b"/usr/sbin/screencapture") { + continue; + } + if let Ok(arguments) = process_arguments(pid) + && toolbar_arguments(&arguments) + { + return Ok(true); + } + } + Ok(false) +} + +fn process_arguments(pid: i32) -> io::Result> { + let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid]; + let mut length = 0; + // SAFETY: sysctl writes only the size when oldp is null; mib has three ints. + if unsafe { + libc::sysctl( + mib.as_mut_ptr(), + 3, + std::ptr::null_mut(), + &mut length, + std::ptr::null_mut(), + 0, + ) + } != 0 + { + return Err(io::Error::last_os_error()); + } + let mut buffer = vec![0u8; length]; + // SAFETY: buffer has the size requested by sysctl. No new value is supplied. + if unsafe { + libc::sysctl( + mib.as_mut_ptr(), + 3, + buffer.as_mut_ptr().cast(), + &mut length, + std::ptr::null_mut(), + 0, + ) + } != 0 + { + return Err(io::Error::last_os_error()); + } + buffer.truncate(length); + Ok(buffer) +} + +fn toolbar_arguments(buffer: &[u8]) -> bool { + let Some(count) = buffer + .get(..4) + .and_then(|bytes| bytes.try_into().ok()) + .map(i32::from_ne_bytes) + else { + return false; + }; + if count <= 0 { + return false; + } + // KERN_PROCARGS2: argc, executable path, NUL padding, argv, environment. + let rest = &buffer[4..]; + let Some(path_end) = rest.iter().position(|byte| *byte == 0) else { + return false; + }; + let rest = &rest[path_end..]; + let Some(start) = rest.iter().position(|byte| *byte != 0) else { + return false; + }; + let mut rest = &rest[start..]; + let mut toolbar = false; + for index in 0..count { + let Some(end) = rest.iter().position(|byte| *byte == 0) else { + return false; + }; + let arg = &rest[..end]; + rest = &rest[end + 1..]; + if index == 0 { + continue; + } + if arg == b"--" { + break; + } + toolbar |= arg == b"keyboard.interactive" + || (arg.starts_with(b"-") && !arg.starts_with(b"--") && arg[1..].contains(&b'U')); + } + toolbar +} + +#[cfg(test)] +mod tests { + use super::*; + + fn arguments(args: &[&str]) -> Vec { + let mut buffer = (args.len() as i32).to_ne_bytes().to_vec(); + buffer.extend_from_slice(b"/usr/sbin/screencapture\0\0"); + for arg in args { + buffer.extend_from_slice(arg.as_bytes()); + buffer.push(0); + } + buffer + } + + #[test] + fn distinguishes_toolbar_from_quick_capture() { + for flags in ["-pdiU", "-U"] { + assert!(toolbar_arguments(&arguments(&["screencapture", flags]))); + } + assert!(toolbar_arguments(&arguments(&[ + "screencapture", + "-z", + "keyboard.interactive" + ]))); + for mode in ["keyboard.selection", "keyboard.screen"] { + assert!(!toolbar_arguments(&arguments(&[ + "screencapture", + "-pdi", + "-z", + mode + ]))); + } + assert!(!toolbar_arguments(&arguments(&["screencapture", "-u"]))); + assert!(!toolbar_arguments(&arguments(&[ + "screencapture", + "--", + "-U" + ]))); + } + + #[test] + fn excludes_environment_and_malformed_arguments() { + let mut buffer = arguments(&["screencapture", "-pd"]); + buffer.extend_from_slice(b"keyboard.interactive\0-U\0"); + assert!(!toolbar_arguments(&buffer)); + assert!(!toolbar_arguments(&[0, 1])); + let mut buffer = arguments(&["screencapture", "-U"]); + buffer.pop(); + assert!(!toolbar_arguments(&buffer)); + } + + #[test] + fn scans_native_process_table() { + is_active().unwrap(); + } + + #[test] + fn reads_native_process_arguments_without_capture_permissions() { + let buffer = process_arguments(std::process::id() as i32).unwrap(); + assert!(!toolbar_arguments(&buffer)); + } +} diff --git a/src-tauri/src/ui/scene/screenshot_picker/mod.rs b/src-tauri/src/ui/scene/screenshot_picker/mod.rs new file mode 100644 index 0000000..956c55a --- /dev/null +++ b/src-tauri/src/ui/scene/screenshot_picker/mod.rs @@ -0,0 +1,102 @@ +//! Temporary suppression, independent of whether puppet policy wants a scene. +//! The scene reconciliation loop owns this monitor, so no detached worker or +//! native observer survives its lifecycle. A future Windows backend implements +//! the same `is_active() -> io::Result` contract. + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +use macos as platform; + +#[cfg(not(target_os = "macos"))] +mod platform { + pub(super) const ENABLED: bool = false; + + pub(super) fn is_active() -> std::io::Result { + Ok(false) + } +} + +use std::time::{Duration, Instant}; + +#[derive(Default)] +pub(super) struct Monitor { + next_sample: Option, + visibility: Suppression, + failed: bool, +} + +impl Monitor { + pub(super) async fn suppressed(&mut self) -> bool { + if !platform::ENABLED { + return false; + } + let now = Instant::now(); + if self.next_sample.is_none_or(|deadline| now >= deadline) { + // Native process inspection stays off the async executor. Only + // one bounded scan is in flight under the scene's mutex. + let result = tauri::async_runtime::spawn_blocking(platform::is_active).await; + let active = match result { + Ok(Ok(active)) => { + self.failed = false; + active + } + error => { + if !self.failed { + eprintln!("Screenshot picker detection failed: {error:?}"); + } + self.failed = true; + // Fail open rather than leaving the scene hidden forever. + false + } + }; + let now = Instant::now(); + self.next_sample = Some(now + Duration::from_millis(500)); + self.visibility.observe(active, now); + } + self.visibility.active + } +} + +#[derive(Default)] +struct Suppression { + active: bool, + clear_since: Option, +} + +impl Suppression { + fn observe(&mut self, active: bool, now: Instant) { + if active { + self.active = true; + self.clear_since = None; + } else if self.active { + let since = *self.clear_since.get_or_insert(now); + // Require another clear scan before restoring; a replacement session + // during dismissal cancels restoration without flashing the scene. + if now.duration_since(since) >= Duration::from_millis(100) { + self.active = false; + self.clear_since = None; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + + #[test] + fn restoration_requires_confirmed_clear_and_reopening_cancels_it() { + let mut state = Suppression::default(); + let now = Instant::now(); + state.observe(true, now); + state.observe(false, now); + assert!(state.active); + state.observe(true, now + Duration::from_millis(200)); + state.observe(false, now + Duration::from_millis(400)); + assert!(state.active); + state.observe(false, now + Duration::from_millis(600)); + assert!(!state.active); + } +}