Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,51 @@
[package]
name = "nomi-computer"
description = "Computer-use tool for Nomi (screenshot, mouse/keyboard synthesis, window control)"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
nomi-types.workspace = true
nomi-protocol.workspace = true
nomi-config.workspace = true
nomi-tools.workspace = true
nomi-a11y.workspace = true
tracing.workspace = true
tokio.workspace = true
serde_json.workspace = true
async-trait.workspace = true
xcap.workspace = true
enigo.workspace = true
image.workspace = true
base64.workspace = true
# Reliable app/URL/file launch via the OS shell. The workspace enables the
# `shellexecute-on-windows` feature so this uses ShellExecuteExW on Windows
# instead of the `cmd /c start` fallback (whose window-title quirk pops the
# "Windows cannot find 'X'" dialog). macOS/Linux use `open`/`xdg-open`.
open.workspace = true
# macOS TCC permission probing (Accessibility / Screen Recording): proactive
# status + prompt via AXIsProcessTrusted(WithOptions) and CG*ScreenCaptureAccess.
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
# Windows: correct absolute mouse actuation across the whole virtual desktop
# (enigo 0.6's Coordinate::Abs normalizes against the PRIMARY monitor only and
# omits MOUSEEVENTF_VIRTUALDESK, mis-projecting multi-monitor / negative-origin
# targets) and real window activation (SetForegroundWindow). 0.61 matches the
# version nomi-a11y already pins.
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_System_Threading",
] }
[dev-dependencies]
# test-util enables tokio::test(start_paused) so wait-clamp tests run instantly.
tokio = { workspace = true, features = ["test-util"] }
@@ -0,0 +1,26 @@
//! Real-machine check for Start-Menu app-name resolution (the `launch` action's
//! fix for "找不到" on bare app names like "QQ音乐").
//!
//! Run: cargo run -p nomi-computer --example appresolve -- "QQ音乐" qqmusic 网易云音乐 notepad
//! Prints the resolved launch target for each name WITHOUT launching anything.
#[cfg(target_os = "windows")]
fn main() {
let names: Vec<String> = std::env::args().skip(1).collect();
let names = if names.is_empty() {
vec!["QQ音乐".to_string(), "qqmusic".to_string(), "notepad".to_string()]
} else {
names
};
for name in names {
match nomi_computer::launch::resolve_app_for_diagnostics(&name) {
Some(t) => println!("{name:?} -> {t:?}"),
None => println!("{name:?} -> (not found in Start Menu)"),
}
}
}
#[cfg(not(target_os = "windows"))]
fn main() {
eprintln!("appresolve is a Windows-only example.");
}
@@ -0,0 +1,230 @@
//! Platform-neutral fallback backend: window enumeration and best-effort
//! focusing via xcap (cross-platform). This is NOT the Windows-OS backend —
//! the file was historically named `windows.rs`, which collided conceptually
//! with the future per-OS UI-Automation submodule; it is renamed to make clear
//! it is the generic xcap/enigo fallback used on every platform.
//!
//! xcap 0.9 has no focus/activate API on any platform, so `focus_window`
//! falls back to clicking the window's center (which raises and focuses it
//! on macOS and most Linux WMs). Window x/y/width/height from xcap are in
//! the same logical coordinate space enigo uses on macOS.
//!
//! Real window activation (macOS NSRunningApplication / Windows UIA SetFocus /
//! Linux per-WM) is designed in the cross-platform computer-use spec and will
//! replace the click-to-raise fallback as the a11y engine lands.
use xcap::Window;
// The click-to-raise focus fallback (and only that) uses synthetic input; on
// Windows we activate the real window via Win32 instead, so the input crate is
// not referenced there.
#[cfg(not(target_os = "windows"))]
use crate::input;
/// A snapshot of one window's metadata.
#[derive(Debug, Clone)]
pub struct WindowInfo {
pub id: u32,
pub title: String,
pub app_name: String,
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
pub is_focused: bool,
}
/// Enumerate windows (front-to-back z order). Blocking: call from
/// spawn_blocking.
pub fn list_windows() -> Result<Vec<WindowInfo>, String> {
let windows = Window::all().map_err(|e| format!("Failed to enumerate windows: {e}"))?;
let mut infos = Vec::with_capacity(windows.len());
for w in &windows {
// Skip windows whose metadata cannot be read instead of failing the
// whole listing.
let Ok(id) = w.id() else { continue };
infos.push(WindowInfo {
id,
title: w.title().unwrap_or_default(),
app_name: w.app_name().unwrap_or_default(),
x: w.x().unwrap_or(0),
y: w.y().unwrap_or(0),
width: w.width().unwrap_or(0),
height: w.height().unwrap_or(0),
is_focused: w.is_focused().unwrap_or(false),
});
}
Ok(infos)
}
/// Render a window list as human-readable text for the LLM.
pub fn format_window_list(windows: &[WindowInfo]) -> String {
if windows.is_empty() {
return "No windows found.".to_string();
}
let mut out = String::from("Windows (front to back):\n");
for w in windows {
let focus = if w.is_focused { " [focused]" } else { "" };
out.push_str(&format!(
"- id={} app={:?} title={:?} at ({}, {}) size {}x{}{}\n",
w.id, w.app_name, w.title, w.x, w.y, w.width, w.height, focus
));
}
out
}
/// Find a window by id. Blocking: call from spawn_blocking.
pub fn find_window(window_id: u32) -> Result<WindowInfo, String> {
let windows = list_windows()?;
windows
.into_iter()
.find(|w| w.id == window_id)
.ok_or_else(|| {
format!(
"Window {window_id} not found. Use the list_windows action to get current ids."
)
})
}
/// Bring a window to the foreground.
///
/// On Windows this uses the real activation API (`SetForegroundWindow` on the
/// exact target HWND, restoring it if minimized, with a foreground-lock
/// workaround) so subsequent `type`/`key` input lands in the intended window.
/// On macOS / Linux, where xcap exposes no activate API and a center click
/// reliably raises and focuses the window, it falls back to click-to-raise.
pub async fn focus_window(window_id: u32) -> Result<String, String> {
let info = tokio::task::spawn_blocking(move || find_window(window_id))
.await
.map_err(|e| format!("Window lookup task failed: {e}"))??;
#[cfg(target_os = "windows")]
{
tokio::task::spawn_blocking(move || set_foreground_window(window_id))
.await
.map_err(|e| format!("Focus task failed: {e}"))??;
Ok(format!(
"Activated window {window_id} ({:?}{:?}) via the Windows foreground API. \
Take a screenshot to verify.",
info.app_name, info.title
))
}
#[cfg(not(target_os = "windows"))]
{
if info.width == 0 || info.height == 0 {
return Err(format!(
"Window {window_id} ({:?}) has zero size; it may be minimized or hidden. \
Cannot focus it by clicking.",
info.title
));
}
let cx = info.x + info.width as i32 / 2;
let cy = info.y + info.height as i32 / 2;
input::click(cx, cy, enigo::Button::Left, 1).await?;
Ok(format!(
"Clicked the center of window {window_id} ({:?}{:?}) at ({cx}, {cy}) to focus it. \
Note: the platform exposes no direct focus API, so this is a click-to-raise fallback; \
the click may interact with whatever is at the window center. \
Take a screenshot to verify the result.",
info.app_name, info.title
))
}
}
/// Windows: activate the exact target window (xcap window ids are HWNDs).
/// Restores it if minimized, briefly attaches our input queue to the current
/// foreground thread so the OS honors the activation (the foreground-stealing
/// lock otherwise silently no-ops), and reports a clear error if Windows still
/// refuses (e.g. an elevated/higher-integrity target).
#[cfg(target_os = "windows")]
fn set_foreground_window(window_id: u32) -> Result<(), String> {
use core::ffi::c_void;
use windows::Win32::Foundation::HWND;
use windows::Win32::System::Threading::{AttachThreadInput, GetCurrentThreadId};
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetWindowThreadProcessId, IsIconic, IsWindow, SW_RESTORE, SW_SHOW,
SetForegroundWindow, ShowWindow,
};
let hwnd = HWND(window_id as usize as *mut c_void);
// SAFETY: window-management calls on a possibly-stale handle. `IsWindow`
// guards validity first, and every call below fails (rather than UB) on an
// invalid HWND.
unsafe {
if !IsWindow(Some(hwnd)).as_bool() {
return Err(format!(
"Window {window_id} no longer exists. Use list_windows to get current ids."
));
}
if IsIconic(hwnd).as_bool() {
let _ = ShowWindow(hwnd, SW_RESTORE);
} else {
let _ = ShowWindow(hwnd, SW_SHOW);
}
// Windows only honors SetForegroundWindow from the thread owning the
// current foreground window, so briefly share its input state.
let cur = GetCurrentThreadId();
let fg = GetForegroundWindow();
let fg_tid = if fg.0.is_null() {
0
} else {
GetWindowThreadProcessId(fg, None)
};
let attached =
fg_tid != 0 && fg_tid != cur && AttachThreadInput(cur, fg_tid, true).as_bool();
let ok = SetForegroundWindow(hwnd).as_bool();
if attached {
let _ = AttachThreadInput(cur, fg_tid, false);
}
if ok {
Ok(())
} else {
Err(format!(
"Windows refused to bring window {window_id} to the foreground (foreground \
activation is OS-restricted; the target may be elevated / higher-integrity than \
this app, or another app holds the foreground lock). Try a pixel click instead."
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_empty_window_list() {
assert_eq!(format_window_list(&[]), "No windows found.");
}
#[test]
fn format_window_list_includes_fields() {
let windows = vec![WindowInfo {
id: 42,
title: "Inbox".to_string(),
app_name: "Mail".to_string(),
x: 10,
y: 20,
width: 800,
height: 600,
is_focused: true,
}];
let text = format_window_list(&windows);
assert!(text.contains("id=42"));
assert!(text.contains("Mail"));
assert!(text.contains("Inbox"));
assert!(text.contains("800x600"));
assert!(text.contains("[focused]"));
}
// Requires a real window server session.
#[test]
#[ignore]
fn list_windows_real() {
let windows = list_windows().expect("should enumerate windows");
// There is at least a desktop-level window in a real session.
assert!(!windows.is_empty());
}
}
@@ -0,0 +1,410 @@
//! Input synthesis via enigo.
//!
//! Enigo handles are not `Send`, so each operation constructs a fresh Enigo
//! inside `tokio::task::spawn_blocking` and the whole blocking task is
//! wrapped in a 10s timeout. Coordinates passed in here are already absolute
//! screen coordinates (mapped from screenshot space by the caller).
use std::time::Duration;
use enigo::{Axis, Button, Direction, Enigo, Keyboard, Mouse, Settings};
// `Coordinate::Abs` is only used on the non-Windows actuation path; Windows
// moves the cursor via SendInput over the virtual desktop instead (see
// `move_abs`).
#[cfg(not(target_os = "windows"))]
use enigo::Coordinate;
use crate::permissions;
const INPUT_TIMEOUT: Duration = Duration::from_secs(10);
/// Pause between press and release (and between repeated clicks) so target
/// apps register distinct events.
const CLICK_PAUSE: Duration = Duration::from_millis(20);
/// Scroll direction accepted by the `scroll` action.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollDirection {
Up,
Down,
Left,
Right,
}
impl ScrollDirection {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"up" => Ok(Self::Up),
"down" => Ok(Self::Down),
"left" => Ok(Self::Left),
"right" => Ok(Self::Right),
other => Err(format!(
"Unknown scroll direction {other:?}. Use one of: up, down, left, right."
)),
}
}
}
/// Map an absolute global virtual-desktop screen coordinate into the 0..=65535
/// normalized range that `SendInput` expects with
/// `MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK`, relative to the
/// virtual-screen rectangle `(v_left, v_top, v_width, v_height)` reported by
/// `GetSystemMetrics(SM_*VIRTUALSCREEN)`. The endpoints `v_left` and
/// `v_left + v_width - 1` map to 0 and 65535 respectively (round-to-nearest);
/// coordinates outside the desktop clamp into range.
///
/// This is the Windows-only fix for enigo 0.6's `Coordinate::Abs`, which
/// normalizes against the PRIMARY monitor (`GetSystemMetrics(SM_CXSCREEN)`) and
/// omits `MOUSEEVENTF_VIRTUALDESK`, so any target on a secondary monitor — or a
/// monitor whose virtual-desktop origin is negative/non-zero — is mis-projected
/// onto the primary display.
#[cfg(target_os = "windows")]
fn normalize_to_virtual_desktop(
x: i32,
y: i32,
v_left: i32,
v_top: i32,
v_width: i32,
v_height: i32,
) -> (i32, i32) {
// Map [origin, origin + extent - 1] onto [0, 65535] (round-to-nearest),
// clamped so out-of-desktop inputs never escape the range.
fn axis(coord: i32, origin: i32, extent: i32) -> i32 {
let span = (extent as i64) - 1;
if span <= 0 {
return 0;
}
let rel = (coord as i64 - origin as i64).max(0);
let n = (rel * 65535 + span / 2) / span;
n.clamp(0, 65535) as i32
}
(axis(x, v_left, v_width), axis(y, v_top, v_height))
}
/// Move the cursor to an absolute global screen coordinate (the space produced
/// by `to_screen()` / xcap monitor origins).
///
/// On Windows we bypass enigo's `Coordinate::Abs` — it normalizes against the
/// primary monitor only and omits `MOUSEEVENTF_VIRTUALDESK`, so multi-monitor
/// and negative/non-zero-origin targets land on the wrong display — and emit a
/// `SendInput` move across the whole virtual desktop. On macOS / Linux enigo
/// already actuates global coordinates correctly, so its path is unchanged.
fn move_abs(enigo: &mut Enigo, x: i32, y: i32) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let _ = enigo;
move_abs_windows(x, y)
}
#[cfg(not(target_os = "windows"))]
{
enigo.move_mouse(x, y, Coordinate::Abs).map_err(input_err)
}
}
/// Windows absolute cursor move over the entire virtual desktop via `SendInput`
/// (`MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK`),
/// normalized against `GetSystemMetrics(SM_*VIRTUALSCREEN)`.
#[cfg(target_os = "windows")]
fn move_abs_windows(x: i32, y: i32) -> Result<(), String> {
use windows::Win32::UI::Input::KeyboardAndMouse::{
INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_MOVE,
MOUSEEVENTF_VIRTUALDESK, MOUSEINPUT, SendInput,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
SM_YVIRTUALSCREEN,
};
// SAFETY: GetSystemMetrics reads global display metrics; no preconditions.
let (v_left, v_top, v_width, v_height) = unsafe {
(
GetSystemMetrics(SM_XVIRTUALSCREEN),
GetSystemMetrics(SM_YVIRTUALSCREEN),
GetSystemMetrics(SM_CXVIRTUALSCREEN),
GetSystemMetrics(SM_CYVIRTUALSCREEN),
)
};
if v_width <= 0 || v_height <= 0 {
return Err(
"Could not read the Windows virtual-screen dimensions for absolute \
cursor positioning."
.to_string(),
);
}
let (nx, ny) = normalize_to_virtual_desktop(x, y, v_left, v_top, v_width, v_height);
let input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx: nx,
dy: ny,
mouseData: 0,
dwFlags: MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK,
time: 0,
dwExtraInfo: 0,
},
},
};
// SAFETY: a single well-formed INPUT value; cbsize matches its size.
let sent = unsafe { SendInput(&[input], std::mem::size_of::<INPUT>() as i32) };
if sent == 1 {
Ok(())
} else {
Err(
"Windows refused the synthetic mouse move (SendInput inserted no \
events; input may be blocked by a higher-integrity window or the \
secure desktop)."
.to_string(),
)
}
}
fn new_enigo() -> Result<Enigo, String> {
let settings = Settings {
// Never block the agent on an interactive permission prompt.
open_prompt_to_get_permissions: false,
..Settings::default()
};
Enigo::new(&settings).map_err(|e| {
format!(
"Failed to initialize input synthesis: {e}. {}",
permissions::accessibility_hint_detailed()
)
})
}
/// Run an input operation on a fresh Enigo instance inside spawn_blocking,
/// bounded by a 10s timeout.
async fn with_enigo<T, F>(op: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce(&mut Enigo) -> Result<T, String> + Send + 'static,
{
let handle = tokio::task::spawn_blocking(move || {
let mut enigo = new_enigo()?;
op(&mut enigo)
});
match tokio::time::timeout(INPUT_TIMEOUT, handle).await {
Ok(Ok(result)) => result,
Ok(Err(join_err)) => Err(format!("Input task failed: {join_err}")),
Err(_) => Err(format!(
"Input operation timed out after {}s. The system may be blocking \
synthetic input. {}",
INPUT_TIMEOUT.as_secs(),
permissions::accessibility_hint_detailed()
)),
}
}
fn input_err(e: enigo::InputError) -> String {
format!(
"Input synthesis failed: {e}. {}",
permissions::accessibility_hint()
)
}
/// Move the cursor to absolute screen coordinates.
pub async fn mouse_move(x: i32, y: i32) -> Result<(), String> {
with_enigo(move |enigo| move_abs(enigo, x, y)).await
}
/// Click `button` `count` times at absolute screen coordinates.
pub async fn click(x: i32, y: i32, button: Button, count: u32) -> Result<(), String> {
with_enigo(move |enigo| {
move_abs(enigo, x, y)?;
for i in 0..count {
if i > 0 {
std::thread::sleep(CLICK_PAUSE);
}
enigo.button(button, Direction::Click).map_err(input_err)?;
}
Ok(())
})
.await
}
/// Press at (start), drag to (end), release. Includes intermediate moves so
/// apps that track motion register the drag.
pub async fn drag(start_x: i32, start_y: i32, end_x: i32, end_y: i32) -> Result<(), String> {
with_enigo(move |enigo| {
move_abs(enigo, start_x, start_y)?;
enigo
.button(Button::Left, Direction::Press)
.map_err(input_err)?;
std::thread::sleep(CLICK_PAUSE);
// A few intermediate steps make drags more reliable than a teleport.
const STEPS: i32 = 8;
for i in 1..=STEPS {
let ix = start_x + (end_x - start_x) * i / STEPS;
let iy = start_y + (end_y - start_y) * i / STEPS;
move_abs(enigo, ix, iy)?;
std::thread::sleep(Duration::from_millis(10));
}
enigo
.button(Button::Left, Direction::Release)
.map_err(input_err)?;
Ok(())
})
.await
}
/// Type a unicode string (layout-independent).
pub async fn type_text(text: String) -> Result<(), String> {
with_enigo(move |enigo| enigo.text(&text).map_err(input_err)).await
}
/// Press a key combo: press front-to-back, release back-to-front.
pub async fn key_combo(keys: Vec<enigo::Key>) -> Result<(), String> {
with_enigo(move |enigo| {
let mut pressed: Vec<enigo::Key> = Vec::with_capacity(keys.len());
for key in &keys {
if let Err(e) = enigo.key(*key, Direction::Press) {
// Release anything already held before bailing out.
for held in pressed.iter().rev() {
let _ = enigo.key(*held, Direction::Release);
}
return Err(input_err(e));
}
pressed.push(*key);
}
std::thread::sleep(CLICK_PAUSE);
let mut result = Ok(());
for key in pressed.iter().rev() {
if let Err(e) = enigo.key(*key, Direction::Release) {
result = Err(input_err(e));
}
}
result
})
.await
}
/// Scroll by `amount` wheel clicks in `direction` (optionally moving the
/// cursor to (x, y) first so the scroll lands on the right surface).
pub async fn scroll(
at: Option<(i32, i32)>,
direction: ScrollDirection,
amount: i32,
) -> Result<(), String> {
with_enigo(move |enigo| {
if let Some((x, y)) = at {
move_abs(enigo, x, y)?;
}
let (axis, length) = match direction {
ScrollDirection::Up => (Axis::Vertical, -amount),
ScrollDirection::Down => (Axis::Vertical, amount),
ScrollDirection::Left => (Axis::Horizontal, -amount),
ScrollDirection::Right => (Axis::Horizontal, amount),
};
enigo.scroll(length, axis).map_err(input_err)
})
.await
}
/// Current cursor location in absolute screen coordinates.
pub async fn cursor_position() -> Result<(i32, i32), String> {
with_enigo(|enigo| enigo.location().map_err(input_err)).await
}
/// Size (width, height) of the main display in enigo's coordinate system.
/// Blocking variant for use inside other spawn_blocking sections.
pub fn main_display_size_blocking() -> Result<(i32, i32), String> {
let enigo = new_enigo()?;
enigo.main_display().map_err(input_err)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scroll_direction_parses_all_variants() {
assert_eq!(ScrollDirection::parse("up").unwrap(), ScrollDirection::Up);
assert_eq!(
ScrollDirection::parse("down").unwrap(),
ScrollDirection::Down
);
assert_eq!(
ScrollDirection::parse("left").unwrap(),
ScrollDirection::Left
);
assert_eq!(
ScrollDirection::parse("right").unwrap(),
ScrollDirection::Right
);
}
#[test]
fn scroll_direction_unknown_is_error() {
let err = ScrollDirection::parse("diagonal").unwrap_err();
assert!(err.contains("diagonal"));
}
// --- virtual-desktop coordinate normalization (Windows actuation fix) ---
#[cfg(target_os = "windows")]
#[test]
fn vd_center_of_single_primary_maps_to_midrange() {
// One 1920x1080 monitor at the virtual-desktop origin.
let (nx, ny) = normalize_to_virtual_desktop(960, 540, 0, 0, 1920, 1080);
assert!((32000..=33500).contains(&nx), "nx={nx}");
assert!((32000..=33500).contains(&ny), "ny={ny}");
}
#[cfg(target_os = "windows")]
#[test]
fn vd_point_on_secondary_monitor_maps_to_upper_range() {
// Two 1920-wide monitors side by side; target is the centre of the RIGHT
// (secondary) monitor. The fixed mapping must land in the upper half of
// the 0..65535 range — enigo's primary-only normalization would divide
// 2880 by the primary width (1920) and overflow past 65535 onto the
// primary display.
let (nx, _) = normalize_to_virtual_desktop(2880, 540, 0, 0, 3840, 1080);
assert!(nx > 40000 && nx <= 65535, "nx={nx}");
}
#[cfg(target_os = "windows")]
#[test]
fn vd_point_on_negative_origin_monitor_maps_to_lower_range() {
// A monitor to the LEFT of the primary (negative virtual-desktop origin).
// Target is the centre of that left monitor; it must map to the lower
// half — enigo would produce a negative normalized value (off-screen).
let (nx, _) = normalize_to_virtual_desktop(-960, 540, -1920, 0, 3840, 1080);
assert!(nx > 10000 && nx < 25000, "nx={nx}");
}
#[cfg(target_os = "windows")]
#[test]
fn vd_endpoints_map_to_full_range() {
// Left/top edge -> 0, right/bottom edge -> 65535, with a non-zero origin.
assert_eq!(normalize_to_virtual_desktop(100, 50, 100, 50, 1920, 1080).0, 0);
assert_eq!(
normalize_to_virtual_desktop(100 + 1920 - 1, 50, 100, 50, 1920, 1080).0,
65535
);
}
#[cfg(target_os = "windows")]
#[test]
fn vd_out_of_desktop_clamps() {
// Beyond the right/below the left edge -> clamped, never out of [0,65535].
assert_eq!(normalize_to_virtual_desktop(99999, 0, 0, 0, 1920, 1080).0, 65535);
assert_eq!(normalize_to_virtual_desktop(-99999, 0, 0, 0, 1920, 1080).0, 0);
}
// Requires a real input device and (on macOS) Accessibility permission.
#[tokio::test]
#[ignore]
async fn cursor_position_real() {
let (x, y) = cursor_position().await.expect("should read cursor");
assert!(x >= -20_000 && x <= 20_000);
assert!(y >= -20_000 && y <= 20_000);
}
#[tokio::test]
#[ignore]
async fn mouse_move_real() {
mouse_move(10, 10).await.expect("should move cursor");
}
}
@@ -0,0 +1,240 @@
//! Parse xdotool-style key combos ("cmd+shift+t") into enigo keys.
//!
//! Aliases are case-insensitive. Single characters map to `Key::Unicode`;
//! ASCII letters are lowercased because shift is expressed as an explicit
//! modifier, not via capitalization.
use enigo::Key;
/// Parse a "+"-separated key combo into the keys to press, in input order.
/// The caller presses them front-to-back and releases back-to-front.
pub fn parse_key_combo(combo: &str) -> Result<Vec<Key>, String> {
let trimmed = combo.trim();
if trimmed.is_empty() {
return Err("Key combo is empty. Provide e.g. \"enter\" or \"cmd+shift+t\".".to_string());
}
let mut keys = Vec::new();
for token in trimmed.split('+') {
let token = token.trim();
if token.is_empty() {
return Err(format!(
"Malformed key combo {combo:?}: empty segment between '+' separators."
));
}
keys.push(parse_single_key(token)?);
}
Ok(keys)
}
/// Parse one token (a named key alias or a single character).
fn parse_single_key(token: &str) -> Result<Key, String> {
let lower = token.to_ascii_lowercase();
let key = match lower.as_str() {
// Modifiers.
//
// `cmd`/`command` is the macOS-idiomatic "primary" accelerator. Models
// trained on macOS habitually emit "cmd+c"/"cmd+a"/"cmd+shift+t" for
// copy/select-all/reopen-tab. On macOS that is the Command key (Meta);
// on Windows/Linux the equivalent accelerator is Control. Mapping `cmd`
// to Meta everywhere is wrong off macOS — enigo's `Key::Meta` is the
// Win/Super key there, so "cmd+c" would fire Win+C (Copilot) instead of
// copy. Remap `cmd`/`command` per-platform.
//
// `super`/`win`/`meta` stay Meta on every platform: a model that asks
// for those explicitly means the OS/Super key, not the accelerator.
#[cfg(target_os = "macos")]
"cmd" | "command" => Key::Meta,
#[cfg(not(target_os = "macos"))]
"cmd" | "command" => Key::Control,
"super" | "win" | "meta" => Key::Meta,
"ctrl" | "control" => Key::Control,
"alt" | "option" | "opt" => Key::Alt,
"shift" => Key::Shift,
// Whitespace / editing
"enter" | "return" => Key::Return,
"esc" | "escape" => Key::Escape,
"tab" => Key::Tab,
"space" => Key::Space,
"backspace" => Key::Backspace,
"delete" | "del" => Key::Delete,
// Navigation
"up" => Key::UpArrow,
"down" => Key::DownArrow,
"left" => Key::LeftArrow,
"right" => Key::RightArrow,
"home" => Key::Home,
"end" => Key::End,
"pageup" | "page_up" | "pgup" => Key::PageUp,
"pagedown" | "page_down" | "pgdn" => Key::PageDown,
// Function keys
"f1" => Key::F1,
"f2" => Key::F2,
"f3" => Key::F3,
"f4" => Key::F4,
"f5" => Key::F5,
"f6" => Key::F6,
"f7" => Key::F7,
"f8" => Key::F8,
"f9" => Key::F9,
"f10" => Key::F10,
"f11" => Key::F11,
"f12" => Key::F12,
_ => {
let mut chars = token.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => Key::Unicode(c.to_ascii_lowercase()),
_ => {
return Err(format!(
"Unknown key {token:?}. Use a single character or one of: \
cmd, ctrl, alt, shift, enter, esc, tab, space, backspace, \
delete, up, down, left, right, home, end, pageup, pagedown, f1-f12."
));
}
}
}
};
Ok(key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_named_key() {
assert_eq!(parse_key_combo("enter").unwrap(), vec![Key::Return]);
assert_eq!(parse_key_combo("return").unwrap(), vec![Key::Return]);
assert_eq!(parse_key_combo("esc").unwrap(), vec![Key::Escape]);
assert_eq!(parse_key_combo("escape").unwrap(), vec![Key::Escape]);
assert_eq!(parse_key_combo("tab").unwrap(), vec![Key::Tab]);
assert_eq!(parse_key_combo("space").unwrap(), vec![Key::Space]);
assert_eq!(parse_key_combo("backspace").unwrap(), vec![Key::Backspace]);
assert_eq!(parse_key_combo("delete").unwrap(), vec![Key::Delete]);
}
#[test]
fn arrow_and_navigation_keys() {
assert_eq!(parse_key_combo("up").unwrap(), vec![Key::UpArrow]);
assert_eq!(parse_key_combo("down").unwrap(), vec![Key::DownArrow]);
assert_eq!(parse_key_combo("left").unwrap(), vec![Key::LeftArrow]);
assert_eq!(parse_key_combo("right").unwrap(), vec![Key::RightArrow]);
assert_eq!(parse_key_combo("home").unwrap(), vec![Key::Home]);
assert_eq!(parse_key_combo("end").unwrap(), vec![Key::End]);
assert_eq!(parse_key_combo("pageup").unwrap(), vec![Key::PageUp]);
assert_eq!(parse_key_combo("pagedown").unwrap(), vec![Key::PageDown]);
}
#[test]
fn modifier_aliases() {
// `cmd`/`command` is the macOS primary accelerator; on macOS it is Meta,
// elsewhere it is Control (enigo's Meta is Win/Super off macOS).
#[cfg(target_os = "macos")]
let cmd_key = Key::Meta;
#[cfg(not(target_os = "macos"))]
let cmd_key = Key::Control;
for alias in ["cmd", "command"] {
assert_eq!(parse_key_combo(alias).unwrap(), vec![cmd_key], "{alias}");
}
// `super`/`win`/`meta` are the OS/Super key on every platform.
for alias in ["super", "win", "meta"] {
assert_eq!(parse_key_combo(alias).unwrap(), vec![Key::Meta], "{alias}");
}
for alias in ["ctrl", "control"] {
assert_eq!(
parse_key_combo(alias).unwrap(),
vec![Key::Control],
"{alias}"
);
}
for alias in ["alt", "option", "opt"] {
assert_eq!(parse_key_combo(alias).unwrap(), vec![Key::Alt], "{alias}");
}
assert_eq!(parse_key_combo("shift").unwrap(), vec![Key::Shift]);
}
#[test]
fn function_keys() {
assert_eq!(parse_key_combo("f1").unwrap(), vec![Key::F1]);
assert_eq!(parse_key_combo("F5").unwrap(), vec![Key::F5]);
assert_eq!(parse_key_combo("f12").unwrap(), vec![Key::F12]);
assert!(parse_key_combo("f13").is_err());
}
#[test]
fn single_character_key() {
assert_eq!(parse_key_combo("a").unwrap(), vec![Key::Unicode('a')]);
assert_eq!(parse_key_combo("/").unwrap(), vec![Key::Unicode('/')]);
assert_eq!(parse_key_combo("0").unwrap(), vec![Key::Unicode('0')]);
}
#[test]
fn uppercase_single_char_is_lowercased() {
// Shift is an explicit modifier; "T" alone means the 't' key.
assert_eq!(parse_key_combo("T").unwrap(), vec![Key::Unicode('t')]);
}
#[test]
fn combo_preserves_modifier_order() {
// `cmd` resolves per-platform (Meta on macOS, Control elsewhere).
#[cfg(target_os = "macos")]
let cmd_key = Key::Meta;
#[cfg(not(target_os = "macos"))]
let cmd_key = Key::Control;
assert_eq!(
parse_key_combo("cmd+shift+t").unwrap(),
vec![cmd_key, Key::Shift, Key::Unicode('t')]
);
assert_eq!(
parse_key_combo("shift+cmd+t").unwrap(),
vec![Key::Shift, cmd_key, Key::Unicode('t')]
);
}
#[test]
fn combo_case_insensitive_aliases() {
#[cfg(target_os = "macos")]
let cmd_key = Key::Meta;
#[cfg(not(target_os = "macos"))]
let cmd_key = Key::Control;
assert_eq!(
parse_key_combo("CMD+SHIFT+Enter").unwrap(),
vec![cmd_key, Key::Shift, Key::Return]
);
assert_eq!(
parse_key_combo("Ctrl+Alt+Delete").unwrap(),
vec![Key::Control, Key::Alt, Key::Delete]
);
}
#[test]
fn combo_with_whitespace_around_tokens() {
#[cfg(target_os = "macos")]
let cmd_key = Key::Meta;
#[cfg(not(target_os = "macos"))]
let cmd_key = Key::Control;
assert_eq!(
parse_key_combo(" cmd + t ").unwrap(),
vec![cmd_key, Key::Unicode('t')]
);
}
#[test]
fn empty_string_is_error() {
assert!(parse_key_combo("").is_err());
assert!(parse_key_combo(" ").is_err());
}
#[test]
fn empty_segment_is_error() {
assert!(parse_key_combo("cmd+").is_err());
assert!(parse_key_combo("+t").is_err());
assert!(parse_key_combo("cmd++t").is_err());
}
#[test]
fn unknown_key_is_error_and_names_token() {
let err = parse_key_combo("cmd+bogus").unwrap_err();
assert!(err.contains("bogus"), "error should name the token: {err}");
}
}
@@ -0,0 +1,420 @@
//! Reliable app / URL / file launch via the OS shell.
//!
//! On Windows this does two things `cmd /c start` and a naive `ShellExecute`
//! cannot:
//! 1. **Resolve an application by name.** A bare display name like "QQ音乐" or
//! "notepad" is not a file/URL/registered-app, so `ShellExecute("QQ音乐")`
//! fails with ERROR_CANCELLED (1223) after popping the "Windows can't find"
//! chooser. We instead resolve the name through the Start Menu
//! (`Get-StartApps`, fuzzy-matched), turn the resulting AppID into a real
//! launch target (a resolved `.exe` path, or `shell:AppsFolder\<AUMID>` for
//! packaged apps), and ShellExecute THAT — which works.
//! 2. **Open URLs/files** via `ShellExecuteExW` (the `open` crate with the
//! workspace's `shellexecute-on-windows` feature), the same path a
//! double-click uses — no `cmd /c start` window-title quirk, no dialog.
//!
//! macOS/Linux fall through to `open` / `xdg-open` (which already resolve apps by
//! name well enough); the Start-Menu resolution is Windows-only.
/// Reject the degenerate targets that make the Windows shell pop a "cannot find"
/// dialog: empty/whitespace, or a string that is nothing but path separators
/// (`\`, `\\`, `//`) — the exact shape behind the `\` dialog.
pub fn validate_launch_target(target: &str) -> Result<(), String> {
let t = target.trim();
if t.is_empty() {
return Err("launch target is empty".to_string());
}
if t.chars().all(|c| c == '\\' || c == '/') {
return Err(format!(
"launch target {target:?} is just path separators — give a URL (https://…), a file or \
folder path, or an application name"
));
}
Ok(())
}
/// True if `target` looks like a URL or registered protocol (`https://…`,
/// `mailto:…`, `microsoft-edge:…`) — those ShellExecute directly and must not be
/// run through Start-Menu app resolution. A drive-letter path (`C:\…`) is NOT a
/// URL (single-letter scheme is rejected).
fn is_url(target: &str) -> bool {
if target.contains("://") {
return true;
}
match target.find(':') {
Some(i) if i > 1 => target[..i]
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.'),
_ => false,
}
}
/// Launch `target` (a URL, file/folder path, or application name) reliably via
/// the OS shell, optionally opening it WITH a specific `app`. Detached. Returns
/// a human-readable success message.
pub async fn launch(target: &str, app: Option<&str>) -> Result<String, String> {
validate_launch_target(target)?;
let target = target.to_string();
let app = app.map(|s| s.to_string());
tokio::task::spawn_blocking(move || launch_blocking(&target, app.as_deref()))
.await
.unwrap_or_else(|e| Err(format!("launch task failed: {e}")))
}
/// Blocking launch (runs on a `spawn_blocking` thread): resolve the target if it
/// is a bare Windows app name, then ShellExecute it.
fn launch_blocking(target: &str, app: Option<&str>) -> Result<String, String> {
// Opening a target WITH a specific app (e.g. a URL in a chosen browser):
// the app handler resolves it; no Start-Menu lookup.
if let Some(a) = app {
return open::with_detached(target, a)
.map(|()| format!("Opened {target:?} with {a:?}."))
.map_err(|e| format!("failed to open {target:?} with {a:?}: {e}"));
}
// Windows: resolve a bare application NAME through the Start Menu before
// ShellExecuting it. URLs and existing paths skip resolution (they open
// directly).
#[cfg(target_os = "windows")]
{
if !is_url(target) && !std::path::Path::new(target).exists() {
if let Some(resolved) = resolve_start_app(target) {
return open::that_detached(&resolved)
.map(|()| format!("Launched {target:?} (resolved via the Start Menu to {resolved:?})."))
.map_err(|e| {
format!("found {target:?} in the Start Menu ({resolved:?}) but failed to launch it: {e}")
});
}
// Not in the Start Menu — best-effort raw open, with a clear error so
// the model knows to use the exact name / a full path instead of
// retrying the same string.
return open::that_detached(target)
.map(|()| format!("Opened {target:?}."))
.map_err(|e| {
format!(
"could not find an application named {target:?} in the Start Menu, and the \
OS could not open it directly ({e}). Use the app's exact Start-menu name, \
or a full path to its .exe."
)
});
}
}
// URL / existing path (and all non-Windows launches).
open::that_detached(target)
.map(|()| format!("Opened {target:?}."))
.map_err(|e| format!("failed to open {target:?}: {e}"))
}
// ---- Windows Start-Menu application resolution --------------------------
/// Resolve a bare application name to a ShellExecute-able launch target via the
/// Start Menu. `None` when no Start-Menu app matches.
#[cfg(target_os = "windows")]
fn resolve_start_app(name: &str) -> Option<String> {
let apps = get_start_apps();
let app_id = match_app(&apps, name)?;
Some(app_id_to_launch_target(&app_id))
}
/// Diagnostic: resolve an application name to its launch target WITHOUT launching
/// it (Windows only). Used by the `appresolve` example to verify Start-Menu
/// resolution end-to-end on a real machine. `None` if nothing matches.
#[cfg(target_os = "windows")]
pub fn resolve_app_for_diagnostics(name: &str) -> Option<String> {
resolve_start_app(name)
}
/// Enumerate Start-Menu apps as `(display_name, app_id)` via PowerShell
/// `Get-StartApps`. Uses `-EncodedCommand` (base64 UTF-16LE) to avoid all
/// argument-quoting pitfalls, and `CREATE_NO_WINDOW` so no console flashes.
/// Returns empty on any failure (caller falls back to a raw open).
#[cfg(target_os = "windows")]
fn get_start_apps() -> Vec<(String, String)> {
use base64::Engine;
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
// Tab-separated Name<TAB>AppID per line (names/AppIDs don't contain tabs).
let script = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; \
Get-StartApps | ForEach-Object { \"$($_.Name)`t$($_.AppID)\" }";
let encoded = {
let utf16: Vec<u8> = script.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
base64::engine::general_purpose::STANDARD.encode(utf16)
};
let output = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded])
.creation_flags(CREATE_NO_WINDOW)
.output();
let Ok(out) = output else {
return Vec::new();
};
let text = String::from_utf8_lossy(&out.stdout);
parse_start_apps(&text)
}
/// Parse `Get-StartApps` tab-separated output into `(name, app_id)` pairs (pure).
#[cfg(target_os = "windows")]
fn parse_start_apps(text: &str) -> Vec<(String, String)> {
text.lines()
.filter_map(|line| {
let mut it = line.splitn(2, '\t');
let name = it.next()?.trim();
let app_id = it.next()?.trim();
if name.is_empty() || app_id.is_empty() {
None
} else {
Some((name.to_string(), app_id.to_string()))
}
})
.collect()
}
/// Normalize for matching: lowercase, drop all whitespace.
#[cfg(target_os = "windows")]
fn norm(s: &str) -> String {
s.chars()
.filter(|c| !c.is_whitespace())
.flat_map(|c| c.to_lowercase())
.collect()
}
/// The exe basename of an AppID path (without `.exe`), for matching a query like
/// "qqmusic" against `…\QQMusic.exe`. Empty for AUMIDs (no `\`).
#[cfg(target_os = "windows")]
fn exe_basename(app_id: &str) -> String {
if !app_id.contains('\\') {
return String::new();
}
let base = app_id.rsplit('\\').next().unwrap_or(app_id);
base.strip_suffix(".exe")
.or_else(|| base.strip_suffix(".EXE"))
.unwrap_or(base)
.to_string()
}
/// Pick the best-matching AppID for `query` from the Start-Menu list (pure).
/// Priority: exact (normalized) match on the display name or the exe basename;
/// then a containment match (shortest name = most specific). `None` if nothing
/// reasonable matches.
#[cfg(target_os = "windows")]
fn match_app(apps: &[(String, String)], query: &str) -> Option<String> {
let q = norm(query);
if q.is_empty() {
return None;
}
let mut best_contains: Option<(&str, usize)> = None;
for (name, app_id) in apps {
let nn = norm(name);
let bn = norm(&exe_basename(app_id));
if nn == q || (!bn.is_empty() && bn == q) {
return Some(app_id.clone()); // exact wins immediately
}
if q.len() >= 2 && (nn.contains(&q) || (!bn.is_empty() && bn.contains(&q))) {
let score = name.chars().count();
if best_contains.map(|(_, s)| score < s).unwrap_or(true) {
best_contains = Some((app_id, score));
}
}
}
best_contains.map(|(a, _)| a.to_string())
}
/// Split a `{KNOWN-FOLDER-GUID}\rest` AppID into the GUID and the remainder
/// (pure). `None` when there is no leading `{…}` brace group.
#[cfg(target_os = "windows")]
fn split_guid_prefix(app_id: &str) -> Option<(&str, &str)> {
if !app_id.starts_with('{') {
return None;
}
let end = app_id.find('}')?;
let guid = &app_id[..=end];
let rest = app_id[end + 1..].trim_start_matches('\\');
Some((guid, rest))
}
/// Convert a `Get-StartApps` AppID into a ShellExecute-able launch target.
/// * `{GUID}\rest` → resolve the known-folder GUID and join `rest` (a real path).
/// * other `…\…` → a plain absolute path, used as-is.
/// * no backslash → an AUMID (packaged app) → `shell:AppsFolder\<AUMID>`.
/// An unresolvable GUID falls back to the raw AppID (best effort).
#[cfg(target_os = "windows")]
fn app_id_to_launch_target(app_id: &str) -> String {
if !app_id.contains('\\') {
return format!("shell:AppsFolder\\{app_id}");
}
if let Some((guid, rest)) = split_guid_prefix(app_id) {
if let Some(base) = known_folder_path(guid) {
return std::path::Path::new(&base)
.join(rest)
.to_string_lossy()
.into_owned();
}
}
app_id.to_string()
}
/// Resolve the common known-folder GUIDs that `Get-StartApps` prefixes desktop
/// app paths with, via their environment variables (no COM/FFI needed). Covers
/// ProgramFiles(x86/x64), Windows/System32, LocalAppData/AppData, UserProfile.
#[cfg(target_os = "windows")]
fn known_folder_path(guid: &str) -> Option<String> {
let g = guid.to_ascii_uppercase();
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let win = || env("SystemRoot").or_else(|| env("windir"));
match g.as_str() {
// FOLDERID_ProgramFilesX86
"{7C5A40EF-A0FB-4BFC-874A-C0F2E0B9FA8E}" => env("ProgramFiles(x86)").or_else(|| env("ProgramFiles")),
// FOLDERID_ProgramFilesX64 / FOLDERID_ProgramFiles
"{6D809377-6AF0-444B-8957-A3773F02200E}" => env("ProgramW6432").or_else(|| env("ProgramFiles")),
"{905E63B6-C1BF-494E-B29C-65B732D3D21A}" => env("ProgramFiles"),
// FOLDERID_Windows
"{F38BF404-1D43-42F2-9305-67DE0B28FC23}" => win(),
// FOLDERID_System (System32) / FOLDERID_SystemX86
"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}" | "{D65231B0-B2F1-4857-A4CE-A8E7C6EA7D27}" => {
win().map(|w| format!("{w}\\System32"))
}
// FOLDERID_LocalAppData
"{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}" => env("LOCALAPPDATA"),
// FOLDERID_RoamingAppData
"{3EB685DB-65F9-4CF6-A03A-E3EF65729F3D}" => env("APPDATA"),
// FOLDERID_Profile
"{5E6C858F-0E22-4760-9AFE-EA3317B67173}" => env("USERPROFILE"),
// FOLDERID_Programs (user Start Menu\Programs)
"{A77F5D77-2E2B-44C3-A6A2-ABA601054A51}" => {
env("APPDATA").map(|a| format!("{a}\\Microsoft\\Windows\\Start Menu\\Programs"))
}
// FOLDERID_CommonPrograms (all-users Start Menu\Programs)
"{0139D44E-6AFE-49F2-8690-3DAFCAE6FFB8}" => {
env("ProgramData").map(|a| format!("{a}\\Microsoft\\Windows\\Start Menu\\Programs"))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty_whitespace_and_bare_separators() {
assert!(validate_launch_target("").is_err());
assert!(validate_launch_target(" ").is_err());
assert!(validate_launch_target("\\").is_err());
assert!(validate_launch_target("//").is_err());
assert!(validate_launch_target("\\\\").is_err());
assert!(validate_launch_target("/\\/").is_err());
}
#[test]
fn accepts_url_path_and_app_name() {
assert!(validate_launch_target("https://www.example.com").is_ok());
assert!(validate_launch_target("C:\\Windows\\notepad.exe").is_ok());
assert!(validate_launch_target("msedge").is_ok());
assert!(validate_launch_target("/home/user/file.txt").is_ok());
}
#[test]
fn is_url_classifies_schemes_not_drive_paths() {
assert!(is_url("https://example.com"));
assert!(is_url("http://x"));
assert!(is_url("mailto:a@b.com"));
assert!(is_url("microsoft-edge:https://x"));
assert!(!is_url("C:\\Program Files\\x.exe")); // drive letter, not a scheme
assert!(!is_url("QQ音乐"));
assert!(!is_url("notepad"));
}
// ---- Windows Start-Menu resolution (pure logic) ----
#[cfg(target_os = "windows")]
mod windows_resolution {
use super::super::*;
/// Real `Get-StartApps` rows from this machine (QQ Music is a
/// GUID-prefixed desktop path; QQ is an AUMID-like bare id).
fn sample() -> Vec<(String, String)> {
vec![
("QQ".to_string(), "QQ".to_string()),
(
"QQ音乐".to_string(),
"{7C5A40EF-A0FB-4BFC-874A-C0F2E0B9FA8E}\\Tencent\\QQMusic\\QQMusic.exe".to_string(),
),
(
"网易云音乐".to_string(),
"{6D809377-6AF0-444B-8957-A3773F02200E}\\NetEase\\CloudMusic\\cloudmusic.exe".to_string(),
),
("Notepad".to_string(), "Microsoft.Windows.Notepad_8wekyb3d8bbwe!App".to_string()),
]
}
#[test]
fn parse_start_apps_splits_tab_separated() {
let out = "QQ音乐\t{7C5A40EF}\\a\\b.exe\nNotepad\tNotepad.AUMID\n\n";
let v = parse_start_apps(out);
assert_eq!(v.len(), 2);
assert_eq!(v[0].0, "QQ音乐");
assert_eq!(v[0].1, "{7C5A40EF}\\a\\b.exe");
assert_eq!(v[1], ("Notepad".to_string(), "Notepad.AUMID".to_string()));
}
#[test]
fn match_app_exact_chinese_name() {
let m = match_app(&sample(), "QQ音乐").unwrap();
assert!(m.contains("QQMusic.exe"), "got {m}");
}
#[test]
fn match_app_matches_exe_basename_for_qqmusic() {
// "qqmusic" matches the exe basename of QQ音乐's AppID, not its name.
let m = match_app(&sample(), "qqmusic").unwrap();
assert!(m.contains("QQMusic.exe"), "got {m}");
}
#[test]
fn match_app_contains_for_partial_chinese() {
let m = match_app(&sample(), "网易云").unwrap();
assert!(m.contains("cloudmusic.exe"), "got {m}");
}
#[test]
fn match_app_aumid_exact() {
let m = match_app(&sample(), "notepad").unwrap();
assert!(m.contains("Microsoft.Windows.Notepad"), "got {m}");
}
#[test]
fn match_app_none_for_unknown() {
assert!(match_app(&sample(), "definitely-not-an-app-xyz").is_none());
}
#[test]
fn split_guid_prefix_extracts_guid_and_rest() {
let (g, r) = split_guid_prefix("{7C5A40EF-A0FB-4BFC-874A-C0F2E0B9FA8E}\\Tencent\\QQMusic\\QQMusic.exe").unwrap();
assert_eq!(g, "{7C5A40EF-A0FB-4BFC-874A-C0F2E0B9FA8E}");
assert_eq!(r, "Tencent\\QQMusic\\QQMusic.exe");
assert!(split_guid_prefix("C:\\plain\\path.exe").is_none());
assert!(split_guid_prefix("Some.AUMID!App").is_none());
}
#[test]
fn app_id_to_launch_target_aumid_and_plain_path() {
assert_eq!(
app_id_to_launch_target("Microsoft.Windows.Notepad_8wekyb3d8bbwe!App"),
"shell:AppsFolder\\Microsoft.Windows.Notepad_8wekyb3d8bbwe!App"
);
assert_eq!(app_id_to_launch_target("C:\\plain\\x.exe"), "C:\\plain\\x.exe");
}
#[test]
fn app_id_to_launch_target_resolves_program_files_x86_guid() {
// {7C5A40EF…} = ProgramFilesX86 → real path under %ProgramFiles(x86)%.
let t = app_id_to_launch_target(
"{7C5A40EF-A0FB-4BFC-874A-C0F2E0B9FA8E}\\Tencent\\QQMusic\\QQMusic.exe",
);
assert!(t.ends_with("\\Tencent\\QQMusic\\QQMusic.exe"), "got {t}");
assert!(!t.starts_with('{'), "GUID must be resolved away: {t}");
}
}
}
@@ -0,0 +1,15 @@
pub mod fallback_backend;
pub mod input;
pub mod keys;
pub mod launch;
pub mod permissions;
pub mod scale;
pub mod screen;
pub mod tool;
pub use tool::ComputerTool;
// Re-exported so a host that pulls computer-use (but not nomi-a11y directly)
// can brand the permission-error guidance with its own app name — see
// `nomi_a11y::set_host_app_label`.
pub use nomi_a11y::{host_app_label, set_host_app_label};
@@ -0,0 +1,310 @@
//! Best-effort OS permission diagnostics for screen capture and input.
//!
//! macOS gates screen capture behind "Screen Recording" and input synthesis
//! behind "Accessibility". This module provides PROACTIVE status probing and
//! prompting (via TCC APIs) plus the legacy reactive hints/heuristics as
//! corroborating fallbacks. On non-macOS platforms the status calls report
//! "unknown" (`None`) and requests are no-ops.
/// A point-in-time snapshot of the two TCC permissions computer-use needs.
/// `None` for a field means the platform cannot report it (non-macOS).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PermissionStatus {
pub accessibility: Option<bool>,
pub screen_recording: Option<bool>,
}
/// Live Accessibility (input synthesis / future a11y-tree) grant state.
/// `None` where the platform has no such gate.
pub fn accessibility_granted() -> Option<bool> {
#[cfg(target_os = "macos")]
{
Some(macos_tcc::accessibility_trusted())
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
/// Live Screen Recording (screenshot) grant state. `None` off macOS.
pub fn screen_recording_granted() -> Option<bool> {
#[cfg(target_os = "macos")]
{
Some(macos_tcc::screen_capture_granted())
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
/// Snapshot both permissions at once (for a host/UI permission panel).
pub fn permission_status() -> PermissionStatus {
PermissionStatus {
accessibility: accessibility_granted(),
screen_recording: screen_recording_granted(),
}
}
/// Trigger the OS Accessibility prompt (macOS shows the system dialog and the
/// Settings deep-link). Returns the post-call grant state. No-op → `true`
/// where the platform has no such gate.
pub fn request_accessibility() -> bool {
#[cfg(target_os = "macos")]
{
macos_tcc::request_accessibility()
}
#[cfg(not(target_os = "macos"))]
{
true
}
}
/// Trigger the OS Screen Recording prompt (macOS). Returns the post-call grant
/// state. Note: macOS caches the grant per process, so a freshly-granted
/// permission typically requires an app relaunch to take effect.
pub fn request_screen_recording() -> bool {
#[cfg(target_os = "macos")]
{
macos_tcc::request_screen_capture()
}
#[cfg(not(target_os = "macos"))]
{
true
}
}
fn live_status_note(granted: Option<bool>, name: &str) -> String {
match granted {
Some(false) => format!(" (checked just now: {name} is NOT granted)"),
Some(true) => format!(" (checked just now: {name} is granted)"),
None => String::new(),
}
}
/// `screen_capture_hint()` plus the live grant state when known.
pub fn screen_capture_hint_detailed() -> String {
format!(
"{}{}",
screen_capture_hint(),
live_status_note(screen_recording_granted(), "Screen Recording")
)
}
/// `accessibility_hint()` plus the live grant state when known.
pub fn accessibility_hint_detailed() -> String {
format!(
"{}{}",
accessibility_hint(),
live_status_note(accessibility_granted(), "Accessibility")
)
}
/// Guidance appended to screen-capture failures. Names the host app (see
/// [`nomi_a11y::host_app_label`]) and stresses the relaunch, because macOS never
/// hot-loads Screen Recording into an already-running process.
pub fn screen_capture_hint() -> String {
if cfg!(target_os = "macos") {
let app = nomi_a11y::host_app_label();
format!(
"If this keeps failing, grant Screen Recording permission to {app} in \
System Settings → Privacy & Security → Screen Recording, then COMPLETELY quit and \
reopen {app} (a freshly-granted Screen Recording permission only takes effect after \
a relaunch)."
)
} else {
"Check that a display is connected and the app is allowed to capture the screen.".to_string()
}
}
/// Guidance appended to input-synthesis failures. Names the host app and the
/// relaunch — the generic "this app" otherwise leads a model to send the user
/// to grant a terminal/editor rather than the desktop host itself.
pub fn accessibility_hint() -> String {
if cfg!(target_os = "macos") {
let app = nomi_a11y::host_app_label();
format!(
"If this keeps failing, grant Accessibility permission to {app} in \
System Settings → Privacy & Security → Accessibility, then COMPLETELY quit and reopen \
{app}. Computer-use runs inside {app} itself, so grant {app} — not a terminal or editor."
)
} else {
"Check that the app is allowed to control the mouse and keyboard.".to_string()
}
}
/// Verify a captured frame is usable. macOS without Screen Recording can
/// "succeed" but return an all-black frame; flag that. The authoritative TCC
/// preflight lives at the capture call site (`screen::capture_screen`); this
/// stays a pure, environment-independent heuristic so it remains unit-testable
/// and also catches edge cases where the preflight and the real capture
/// disagree (e.g. the macOS 26 "responsible process" drift). Always Ok off
/// macOS.
pub fn screenshot_permission_check(img: &image::RgbaImage) -> Result<(), String> {
if cfg!(target_os = "macos") && looks_all_black(img) {
return Err(format!(
"Screenshot came back entirely black, which usually means the \
Screen Recording permission is missing or stale. {}",
screen_capture_hint_detailed()
));
}
Ok(())
}
/// macOS TCC FFI: proactive Accessibility / Screen Recording status + prompt.
#[cfg(target_os = "macos")]
mod macos_tcc {
use core_foundation::base::TCFType;
use core_foundation::boolean::CFBoolean;
use core_foundation::dictionary::{CFDictionary, CFDictionaryRef};
use core_foundation::string::{CFString, CFStringRef};
// HIServices (umbrella: ApplicationServices). `Boolean` is `unsigned char`.
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
fn AXIsProcessTrusted() -> u8;
fn AXIsProcessTrustedWithOptions(options: CFDictionaryRef) -> u8;
static kAXTrustedCheckOptionPrompt: CFStringRef;
}
// CoreGraphics screen-capture access (C `bool`).
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGPreflightScreenCaptureAccess() -> bool;
fn CGRequestScreenCaptureAccess() -> bool;
}
pub fn accessibility_trusted() -> bool {
unsafe { AXIsProcessTrusted() != 0 }
}
/// Probe Accessibility AND show the system prompt + Settings deep-link when
/// not yet granted.
pub fn request_accessibility() -> bool {
unsafe {
let key = CFString::wrap_under_get_rule(kAXTrustedCheckOptionPrompt);
let opts = CFDictionary::from_CFType_pairs(&[(
key.as_CFType(),
CFBoolean::true_value().as_CFType(),
)]);
AXIsProcessTrustedWithOptions(opts.as_concrete_TypeRef()) != 0
}
}
pub fn screen_capture_granted() -> bool {
unsafe { CGPreflightScreenCaptureAccess() }
}
pub fn request_screen_capture() -> bool {
unsafe { CGRequestScreenCaptureAccess() }
}
}
/// True if every sampled pixel is (near-)black. Samples a grid rather than
/// every pixel to keep this cheap on Retina-sized captures.
pub fn looks_all_black(img: &image::RgbaImage) -> bool {
let (w, h) = img.dimensions();
if w == 0 || h == 0 {
return true;
}
let step_x = (w / 64).max(1);
let step_y = (h / 64).max(1);
let mut y = 0;
while y < h {
let mut x = 0;
while x < w {
let p = img.get_pixel(x, y);
if p[0] > 2 || p[1] > 2 || p[2] > 2 {
return false;
}
x += step_x;
}
y += step_y;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use image::{Rgba, RgbaImage};
#[test]
fn all_black_image_detected() {
let img = RgbaImage::from_pixel(64, 64, Rgba([0, 0, 0, 255]));
assert!(looks_all_black(&img));
}
#[test]
fn near_black_noise_still_counts_as_black() {
let img = RgbaImage::from_pixel(64, 64, Rgba([1, 2, 1, 255]));
assert!(looks_all_black(&img));
}
#[test]
fn single_bright_pixel_is_not_black() {
let mut img = RgbaImage::from_pixel(64, 64, Rgba([0, 0, 0, 255]));
// Place it on the sampling grid origin so the sparse scan sees it.
img.put_pixel(0, 0, Rgba([255, 255, 255, 255]));
assert!(!looks_all_black(&img));
}
#[test]
fn empty_image_counts_as_black() {
let img = RgbaImage::new(0, 0);
assert!(looks_all_black(&img));
}
#[test]
fn permission_check_passes_on_normal_image() {
let img = RgbaImage::from_pixel(8, 8, Rgba([120, 40, 200, 255]));
assert!(screenshot_permission_check(&img).is_ok());
}
#[test]
fn hints_are_nonempty() {
assert!(!screen_capture_hint().is_empty());
assert!(!accessibility_hint().is_empty());
// Detailed variants embed live status and must still be non-empty.
assert!(!screen_capture_hint_detailed().is_empty());
assert!(!accessibility_hint_detailed().is_empty());
}
// The hints must name the host app on macOS so a model stops sending the
// user to grant a terminal/editor. Off macOS the message is generic.
#[test]
fn macos_hints_name_the_host_app() {
nomi_a11y::set_host_app_label("NomiFun");
if cfg!(target_os = "macos") {
assert!(
accessibility_hint().contains("NomiFun"),
"accessibility hint should name the host app: {}",
accessibility_hint()
);
assert!(
screen_capture_hint().contains("NomiFun"),
"screen-capture hint should name the host app: {}",
screen_capture_hint()
);
}
}
#[test]
fn permission_status_does_not_panic_and_is_consistent() {
// Calls the real TCC APIs on macOS (read-only, safe); no-op elsewhere.
let s = permission_status();
assert_eq!(s.accessibility, accessibility_granted());
assert_eq!(s.screen_recording, screen_recording_granted());
#[cfg(target_os = "macos")]
{
assert!(s.accessibility.is_some());
assert!(s.screen_recording.is_some());
}
#[cfg(not(target_os = "macos"))]
{
assert_eq!(s.accessibility, None);
assert_eq!(s.screen_recording, None);
}
}
}
@@ -0,0 +1,191 @@
//! Pure geometry helpers for screenshot downscaling and coordinate mapping.
//!
//! The LLM sees a (possibly downscaled) screenshot and replies with pixel
//! coordinates in that image. Input synthesis needs logical screen
//! coordinates (on macOS Retina xcap captures physical pixels while enigo
//! expects logical points), so every pointer action goes through
//! `map_llm_coord` and cursor reporting through `map_screen_coord`.
/// Compute downscaled dimensions so the longest edge fits `max_edge`.
/// Returns the original size when it already fits, or when `max_edge` is 0
/// (treated as "no limit"). Never returns a zero dimension.
pub fn fit_within(width: u32, height: u32, max_edge: u32) -> (u32, u32) {
if max_edge == 0 || (width <= max_edge && height <= max_edge) {
return (width, height);
}
let longest = width.max(height) as f64;
let scale = max_edge as f64 / longest;
let w = ((width as f64 * scale).round() as u32).max(1);
let h = ((height as f64 * scale).round() as u32).max(1);
(w, h)
}
/// Map a coordinate in screenshot pixel space to logical screen space.
///
/// Uses pixel-center mapping so the round trip with `map_screen_coord` is
/// exact whenever the logical size is >= the image size. The result is
/// clamped into the screen bounds so out-of-range model output cannot click
/// outside the display.
pub fn map_llm_coord(
llm_x: i32,
llm_y: i32,
img_w: u32,
img_h: u32,
logical_w: u32,
logical_h: u32,
) -> (i32, i32) {
if img_w == 0 || img_h == 0 || logical_w == 0 || logical_h == 0 {
return (llm_x, llm_y);
}
let x = (f64::from(llm_x) + 0.5) * f64::from(logical_w) / f64::from(img_w);
let y = (f64::from(llm_y) + 0.5) * f64::from(logical_h) / f64::from(img_h);
(
(x.floor() as i32).clamp(0, logical_w as i32 - 1),
(y.floor() as i32).clamp(0, logical_h as i32 - 1),
)
}
/// Inverse of `map_llm_coord`: map a logical screen coordinate back to
/// screenshot pixel space (e.g. to report the cursor position in the
/// coordinate system the model is working in).
pub fn map_screen_coord(
screen_x: i32,
screen_y: i32,
img_w: u32,
img_h: u32,
logical_w: u32,
logical_h: u32,
) -> (i32, i32) {
if img_w == 0 || img_h == 0 || logical_w == 0 || logical_h == 0 {
return (screen_x, screen_y);
}
let x = (f64::from(screen_x) + 0.5) * f64::from(img_w) / f64::from(logical_w);
let y = (f64::from(screen_y) + 0.5) * f64::from(img_h) / f64::from(logical_h);
(
(x.floor() as i32).clamp(0, img_w as i32 - 1),
(y.floor() as i32).clamp(0, img_h as i32 - 1),
)
}
#[cfg(test)]
mod tests {
use super::*;
// --- fit_within ---
#[test]
fn fit_within_already_fits() {
assert_eq!(fit_within(800, 600, 1568), (800, 600));
}
#[test]
fn fit_within_exactly_at_limit() {
assert_eq!(fit_within(1568, 980, 1568), (1568, 980));
}
#[test]
fn fit_within_landscape_downscale() {
// Retina MacBook: 2880x1800 physical -> longest edge 1568
assert_eq!(fit_within(2880, 1800, 1568), (1568, 980));
}
#[test]
fn fit_within_portrait_downscale() {
assert_eq!(fit_within(1800, 2880, 1568), (980, 1568));
}
#[test]
fn fit_within_square() {
assert_eq!(fit_within(2000, 2000, 1000), (1000, 1000));
}
#[test]
fn fit_within_extreme_ratio_never_zero() {
// 10000:1 aspect ratio must not collapse the short edge to 0
assert_eq!(fit_within(10000, 1, 100), (100, 1));
assert_eq!(fit_within(1, 10000, 100), (1, 100));
}
#[test]
fn fit_within_zero_max_edge_means_no_limit() {
assert_eq!(fit_within(2880, 1800, 0), (2880, 1800));
}
#[test]
fn fit_within_longest_edge_is_exact() {
let (w, h) = fit_within(2879, 1799, 1568);
assert_eq!(w.max(h), 1568);
assert!(w >= 1 && h >= 1);
}
// --- map_llm_coord ---
#[test]
fn map_llm_identity_when_same_size() {
assert_eq!(map_llm_coord(10, 20, 1440, 900, 1440, 900), (10, 20));
assert_eq!(map_llm_coord(0, 0, 1440, 900, 1440, 900), (0, 0));
assert_eq!(map_llm_coord(1439, 899, 1440, 900, 1440, 900), (1439, 899));
}
#[test]
fn map_llm_corners_map_to_screen_corners() {
// Screenshot 1568x980 of a 1440x900 logical screen (Retina capture
// downscaled, but still larger than logical).
assert_eq!(map_llm_coord(0, 0, 1568, 980, 1440, 900), (0, 0));
assert_eq!(map_llm_coord(1567, 979, 1568, 980, 1440, 900), (1439, 899));
}
#[test]
fn map_llm_center_maps_to_center() {
let (x, y) = map_llm_coord(784, 490, 1568, 980, 1440, 900);
assert!((x - 720).abs() <= 1, "x = {x}");
assert!((y - 450).abs() <= 1, "y = {y}");
}
#[test]
fn map_llm_clamps_out_of_range() {
assert_eq!(map_llm_coord(-50, -50, 1000, 800, 1440, 900), (0, 0));
assert_eq!(
map_llm_coord(99999, 99999, 1000, 800, 1440, 900),
(1439, 899)
);
}
#[test]
fn map_llm_zero_dims_passthrough() {
assert_eq!(map_llm_coord(5, 7, 0, 0, 1440, 900), (5, 7));
assert_eq!(map_llm_coord(5, 7, 1000, 800, 0, 0), (5, 7));
}
// --- round trips ---
#[test]
fn round_trip_exact_when_screen_larger_than_image() {
// image 1000 wide, logical 1440 wide: llm -> screen -> llm is identity
for k in 0..1000 {
let (sx, _) = map_llm_coord(k, 0, 1000, 800, 1440, 900);
let (back, _) = map_screen_coord(sx, 0, 1000, 800, 1440, 900);
assert_eq!(back, k, "round trip failed for {k}");
}
}
#[test]
fn round_trip_within_one_pixel_when_image_larger_than_screen() {
// image 1568 wide, logical 1440 wide: contraction loses at most 1px
for k in (0..1568).step_by(7) {
let (sx, _) = map_llm_coord(k, 0, 1568, 980, 1440, 900);
let (back, _) = map_screen_coord(sx, 0, 1568, 980, 1440, 900);
assert!((back - k).abs() <= 1, "round trip drifted for {k}: {back}");
}
}
#[test]
fn screen_round_trip_exact_when_image_larger_than_screen() {
// screen -> llm -> screen is identity in the contractive direction
for k in 0..1440 {
let (ix, _) = map_screen_coord(k, 0, 1568, 980, 1440, 900);
let (back, _) = map_llm_coord(ix, 0, 1568, 980, 1440, 900);
assert_eq!(back, k, "screen round trip failed for {k}");
}
}
}
@@ -0,0 +1,173 @@
//! Screen capture via xcap, downscaled and PNG/base64-encoded for the LLM.
//!
//! Coordinate systems: `Monitor::width()/height()` report the size enigo's
//! absolute mouse coordinates use (logical points on macOS, device pixels on
//! Windows/Linux), while `capture_image()` returns physical pixels (2x on
//! Retina). `CaptureGeometry` records both so clicks can be mapped back.
use base64::Engine as _;
use xcap::Monitor;
use nomi_types::tool::ToolImage;
use crate::permissions;
use crate::scale::fit_within;
/// Geometry of the most recent capture, used to map LLM (screenshot-pixel)
/// coordinates to absolute screen coordinates.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CaptureGeometry {
/// Dimensions of the image the LLM sees (after downscaling).
pub img_w: u32,
pub img_h: u32,
/// Monitor size in the coordinate system input synthesis uses.
pub logical_w: u32,
pub logical_h: u32,
/// Monitor origin in the global (multi-display) coordinate space.
pub origin_x: i32,
pub origin_y: i32,
}
/// A completed screen capture. `image` is the downscaled RGBA frame (not yet
/// encoded) so callers can draw a Set-of-Marks overlay before encoding; use
/// `encode_png` to produce the `ToolImage`.
#[derive(Debug)]
pub struct CapturedScreen {
pub image: image::RgbaImage,
pub geometry: CaptureGeometry,
/// Raw captured frame size in physical pixels (before downscaling).
pub physical_w: u32,
pub physical_h: u32,
/// Index of the captured monitor within `Monitor::all()`.
pub display_index: usize,
}
/// Encode a (possibly overlay-annotated) RGBA frame as a base64 PNG `ToolImage`.
pub fn encode_png(img: &image::RgbaImage) -> Result<ToolImage, String> {
let mut png = Vec::new();
image::DynamicImage::ImageRgba8(img.clone())
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode screenshot as PNG: {e}"))?;
Ok(ToolImage {
media_type: "image/png".to_string(),
data: base64::engine::general_purpose::STANDARD.encode(&png),
})
}
/// Pick the monitor to capture: explicit index, else the primary, else the
/// first one listed.
fn select_monitor(monitors: &[Monitor], display: Option<usize>) -> Result<usize, String> {
if monitors.is_empty() {
return Err(format!(
"No displays found. {}",
permissions::screen_capture_hint_detailed()
));
}
match display {
Some(idx) => {
if idx < monitors.len() {
Ok(idx)
} else {
Err(format!(
"Display {idx} does not exist; {} display(s) available (0-{}).",
monitors.len(),
monitors.len() - 1
))
}
}
None => Ok(monitors
.iter()
.position(|m| m.is_primary().unwrap_or(false))
.unwrap_or(0)),
}
}
/// Capture a monitor, downscale to `max_edge`, and encode as base64 PNG.
/// Blocking: call from `spawn_blocking`.
pub fn capture_screen(display: Option<usize>, max_edge: u32) -> Result<CapturedScreen, String> {
// Proactive + authoritative on macOS: a denied Screen Recording grant lets
// capture "succeed" with a black frame, so fail fast with a clear message
// instead of relying solely on the downstream black-frame heuristic.
if permissions::screen_recording_granted() == Some(false) {
return Err(format!(
"Screen Recording permission is not granted, so the screen cannot be captured. {}",
permissions::screen_capture_hint_detailed()
));
}
let monitors = Monitor::all().map_err(|e| {
format!(
"Failed to enumerate displays: {e}. {}",
permissions::screen_capture_hint_detailed()
)
})?;
let display_index = select_monitor(&monitors, display)?;
let monitor = &monitors[display_index];
let frame = monitor.capture_image().map_err(|e| {
format!(
"Failed to capture the screen: {e}. {}",
permissions::screen_capture_hint_detailed()
)
})?;
permissions::screenshot_permission_check(&frame)?;
let (physical_w, physical_h) = frame.dimensions();
if physical_w == 0 || physical_h == 0 {
return Err(format!(
"Capture returned an empty frame. {}",
permissions::screen_capture_hint_detailed()
));
}
let (img_w, img_h) = fit_within(physical_w, physical_h, max_edge);
let scaled = if (img_w, img_h) == (physical_w, physical_h) {
frame
} else {
image::imageops::resize(&frame, img_w, img_h, image::imageops::FilterType::Triangle)
};
let logical_w = monitor.width().unwrap_or(physical_w);
let logical_h = monitor.height().unwrap_or(physical_h);
let origin_x = monitor.x().unwrap_or(0);
let origin_y = monitor.y().unwrap_or(0);
Ok(CapturedScreen {
image: scaled,
geometry: CaptureGeometry {
img_w,
img_h,
logical_w,
logical_h,
origin_x,
origin_y,
},
physical_w,
physical_h,
display_index,
})
}
#[cfg(test)]
mod tests {
use super::*;
// Requires a real display and (on macOS) Screen Recording permission.
#[test]
#[ignore]
fn capture_primary_screen_real() {
let captured = capture_screen(None, 1568).expect("capture should succeed");
assert!(captured.image.width() > 0 && captured.image.height() > 0);
let encoded = encode_png(&captured.image).expect("encode should succeed");
assert_eq!(encoded.media_type, "image/png");
assert!(!encoded.data.is_empty());
assert!(captured.geometry.img_w.max(captured.geometry.img_h) <= 1568);
assert!(captured.physical_w >= captured.geometry.img_w);
}
#[test]
#[ignore]
fn capture_invalid_display_errors_real() {
let err = capture_screen(Some(99), 1568).unwrap_err();
assert!(err.contains("99"), "error should name the display: {err}");
}
}
File diff suppressed because it is too large Load Diff