hide scene window when screenshot utility is active

This commit is contained in:
2026-09-07 13:20:36 +08:00
parent 983ce79c8c
commit 83d2d9f2db
5 changed files with 330 additions and 21 deletions
Generated
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"getrandom 0.4.3",
"image",
"lazy_static",
"libc",
"objc2",
"objc2-app-kit",
"objc2-foundation",
+1
View File
@@ -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"
+35 -21
View File
@@ -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<tauri::WebviewWindow, String> {
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<tauri::WebviewWindow, String> {
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<tauri::WebviewWindow, String> {
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<screenshot_picker::Monitor>);
pub(crate) fn is_initialized(handle: &AppHandle) -> bool {
handle.try_state::<SceneWindow>().is_some()
@@ -220,18 +220,11 @@ pub(crate) async fn reconcile_window(handle: &AppHandle, open: bool) -> Result<(
let state = handle
.try_state::<SceneWindow>()
.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::<SceneHitboxes>()
.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::<SceneHitboxes>()
.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());
}
@@ -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<bool> {
// 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::<i32>() + 64];
loop {
let capacity = i32::try_from(pids.len() * size_of::<i32>())
.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::<i32>());
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<Vec<u8>> {
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<u8> {
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));
}
}
@@ -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<bool>` 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<bool> {
Ok(false)
}
}
use std::time::{Duration, Instant};
#[derive(Default)]
pub(super) struct Monitor {
next_sample: Option<Instant>,
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<Instant>,
}
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);
}
}