hide scene window when screenshot utility is active
This commit is contained in:
Generated
+1
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"image",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user