hide scene window when screenshot utility is active
This commit is contained in:
Generated
+1
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"image",
|
"image",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ windows = { version = "0.58", features = [
|
|||||||
] }
|
] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
objc2 = "0.6.3"
|
objc2 = "0.6.3"
|
||||||
objc2-app-kit = "0.3.2"
|
objc2-app-kit = "0.3.2"
|
||||||
objc2-foundation = "0.3.2"
|
objc2-foundation = "0.3.2"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
mod screenshot_picker;
|
||||||
|
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -162,11 +164,7 @@ pub fn overlay_fullscreen(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_window(app_handle: &AppHandle) -> Result<tauri::WebviewWindow, String> {
|
fn open_window(app_handle: &AppHandle, visible: bool) -> Result<(), String> {
|
||||||
if let Some(window) = app_handle.get_webview_window(WINDOW_LABEL) {
|
|
||||||
return Ok(window);
|
|
||||||
};
|
|
||||||
|
|
||||||
let builder = tauri::WebviewWindowBuilder::new(
|
let builder = tauri::WebviewWindowBuilder::new(
|
||||||
app_handle,
|
app_handle,
|
||||||
WINDOW_LABEL,
|
WINDOW_LABEL,
|
||||||
@@ -194,7 +192,9 @@ fn open_window(app_handle: &AppHandle) -> Result<tauri::WebviewWindow, String> {
|
|||||||
let configure = || -> Result<(), tauri::Error> {
|
let configure = || -> Result<(), tauri::Error> {
|
||||||
overlay_fullscreen(app_handle, &window)?;
|
overlay_fullscreen(app_handle, &window)?;
|
||||||
window.set_ignore_cursor_events(true)?;
|
window.set_ignore_cursor_events(true)?;
|
||||||
window.show()?;
|
if visible {
|
||||||
|
window.show()?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
};
|
};
|
||||||
if let Err(error) = configure() {
|
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());
|
apply_macos_decoration_window_policy(app_handle, WINDOW_LABEL.to_string());
|
||||||
track_scene_hitboxes(app_handle.clone(), window.clone());
|
track_scene_hitboxes(app_handle.clone(), window.clone());
|
||||||
|
|
||||||
Ok(window)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serializes native create/destroy requests, including destruction acknowledgement.
|
/// Serializes native create/destroy requests, including destruction acknowledgement.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct SceneWindow(tokio::sync::Mutex<()>);
|
struct SceneWindow(tokio::sync::Mutex<screenshot_picker::Monitor>);
|
||||||
|
|
||||||
pub(crate) fn is_initialized(handle: &AppHandle) -> bool {
|
pub(crate) fn is_initialized(handle: &AppHandle) -> bool {
|
||||||
handle.try_state::<SceneWindow>().is_some()
|
handle.try_state::<SceneWindow>().is_some()
|
||||||
@@ -220,18 +220,11 @@ pub(crate) async fn reconcile_window(handle: &AppHandle, open: bool) -> Result<(
|
|||||||
let state = handle
|
let state = handle
|
||||||
.try_state::<SceneWindow>()
|
.try_state::<SceneWindow>()
|
||||||
.ok_or("scene UI has not started")?;
|
.ok_or("scene UI has not started")?;
|
||||||
let _guard = state.0.lock().await;
|
let mut screenshot_picker = state.0.lock().await;
|
||||||
if open {
|
if !open {
|
||||||
if handle.get_webview_window(WINDOW_LABEL).is_none() {
|
let Some(window) = handle.get_webview_window(WINDOW_LABEL) else {
|
||||||
handle
|
return Ok(());
|
||||||
.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 (sender, receiver) = tokio::sync::oneshot::channel();
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
let sender = std::sync::Mutex::new(Some(sender));
|
let sender = std::sync::Mutex::new(Some(sender));
|
||||||
window.on_window_event(move |event| {
|
window.on_window_event(move |event| {
|
||||||
@@ -249,13 +242,34 @@ pub(crate) async fn reconcile_window(handle: &AppHandle, open: bool) -> Result<(
|
|||||||
.write()
|
.write()
|
||||||
.map_err(|error| error.to_string())?
|
.map_err(|error| error.to_string())?
|
||||||
.clear();
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(app_handle: &AppHandle) {
|
pub fn init(app_handle: &AppHandle) {
|
||||||
app_handle.manage(SceneHitboxes::default());
|
app_handle.manage(SceneHitboxes::default());
|
||||||
// The existing puppet tick opens the scene only after publishing its snapshot.
|
|
||||||
app_handle.manage(SceneWindow::default());
|
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