mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-08-20 14:06:30 +00:00
feat: add window identity, list_windows and focused_window on windows (sub-phase 2.4 U4)
This commit is contained in:
parent
69c6199b0f
commit
63506e69bb
10 changed files with 890 additions and 6 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -90,6 +90,7 @@ dependencies = [
|
|||
"agent-desktop-core",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"uiautomation",
|
||||
"windows",
|
||||
"windows-sys",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ publish = false
|
|||
agent-desktop-core.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
uiautomation = { version = "0.25", default-features = false, features = [
|
||||
|
|
@ -29,7 +30,9 @@ windows-sys = { version = "0.61", features = [
|
|||
"Win32_System_ApplicationInstallationAndServicing",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Graphics_Dwm",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
use agent_desktop_core::{ActionOps, InputOps, ObservationOps};
|
||||
use agent_desktop_core::{
|
||||
ActionOps, AdapterError, Deadline, InputOps, ObservationOps, WindowFilter, WindowInfo,
|
||||
};
|
||||
|
||||
pub struct WindowsAdapter;
|
||||
|
||||
|
|
@ -14,7 +16,16 @@ impl Default for WindowsAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
impl ObservationOps for WindowsAdapter {}
|
||||
impl ObservationOps for WindowsAdapter {
|
||||
fn list_windows(
|
||||
&self,
|
||||
filter: &WindowFilter,
|
||||
_deadline: Deadline,
|
||||
) -> Result<Vec<WindowInfo>, AdapterError> {
|
||||
crate::system::window_ops::list_windows_live(filter)
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionOps for WindowsAdapter {}
|
||||
impl InputOps for WindowsAdapter {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use agent_desktop_core::{
|
||||
AdapterError, AdapterSession, Deadline, InteractionLease, PermissionReport, SessionAffinity,
|
||||
SystemOps,
|
||||
AdapterError, AdapterSession, Deadline, InteractionLease, ObservationOps, PermissionReport,
|
||||
SessionAffinity, SystemOps, WindowFilter, WindowInfo,
|
||||
};
|
||||
|
||||
use crate::adapter::WindowsAdapter;
|
||||
|
|
@ -21,6 +21,19 @@ impl SystemOps for WindowsAdapter {
|
|||
true
|
||||
}
|
||||
|
||||
/// The focused window is the focused-only filter's first result, composed
|
||||
/// from `list_windows` rather than a second native path (KTD10, mirroring
|
||||
/// `crates/macos/src/system/adapter.rs:142-149`). Whatever HWND-shape a
|
||||
/// host presents, it maps to the same identity `list_windows` reports.
|
||||
fn focused_window(&self, deadline: Deadline) -> Result<Option<WindowInfo>, AdapterError> {
|
||||
let filter = WindowFilter {
|
||||
focused_only: true,
|
||||
app: None,
|
||||
};
|
||||
let windows = self.list_windows(&filter, deadline)?;
|
||||
Ok(windows.into_iter().next())
|
||||
}
|
||||
|
||||
fn open_session(
|
||||
&self,
|
||||
_affinity: &SessionAffinity,
|
||||
|
|
|
|||
|
|
@ -5,4 +5,8 @@ pub(crate) mod hresult;
|
|||
pub(crate) mod permissions;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) mod private_file;
|
||||
pub(crate) mod process_identity;
|
||||
pub(crate) mod session;
|
||||
pub(crate) mod window_enum;
|
||||
pub(crate) mod window_identity;
|
||||
pub(crate) mod window_ops;
|
||||
|
|
|
|||
190
crates/windows/src/system/process_identity.rs
Normal file
190
crates/windows/src/system/process_identity.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
use agent_desktop_core::{AdapterError, ProcessId};
|
||||
|
||||
/// The process-generation token KTD3 defines: a creation-time-derived identity
|
||||
/// that survives HWND recycling, mirroring macOS's
|
||||
/// `"macos-proc-v1:{start_seconds}:{start_microseconds}"` shape.
|
||||
///
|
||||
/// Windows FILETIME is 100-nanosecond ticks since 1601; the token keeps the
|
||||
/// integer second and the sub-second tick so two processes started in the same
|
||||
/// second stay distinct, the same way the macOS token's microsecond field
|
||||
/// does. A token is read from the process handle KTD3 already needs, so the
|
||||
/// identity never costs a separate enumeration.
|
||||
const TOKEN_PREFIX: &str = "windows-proc-v1";
|
||||
const TICKS_PER_SECOND: u64 = 10_000_000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ProcessIdentity {
|
||||
pid: ProcessId,
|
||||
creation_seconds: u64,
|
||||
creation_subtick: u64,
|
||||
}
|
||||
|
||||
impl ProcessIdentity {
|
||||
/// Captures the process-generation identity for `pid`.
|
||||
///
|
||||
/// `None` is the honest answer for a process whose token cannot be read
|
||||
/// (an elevated-process handle the caller cannot open, per the split-
|
||||
/// integrity measurement A16-12): the window still lists, with
|
||||
/// `process_instance: None`, and fails closed on resolution (KTD3).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn capture(pid: ProcessId) -> Result<Option<Self>, AdapterError> {
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, FILETIME};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
|
||||
let raw_pid = u32::from(pid);
|
||||
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, raw_pid) };
|
||||
if process.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut created = FILETIME::default();
|
||||
let mut exit = FILETIME::default();
|
||||
let mut kernel = FILETIME::default();
|
||||
let mut user = FILETIME::default();
|
||||
let read_ok =
|
||||
unsafe { GetProcessTimes(process, &mut created, &mut exit, &mut kernel, &mut user) };
|
||||
unsafe { CloseHandle(process) };
|
||||
if read_ok == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let ticks = (u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime);
|
||||
if ticks == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(Self {
|
||||
pid,
|
||||
creation_seconds: ticks / TICKS_PER_SECOND,
|
||||
creation_subtick: ticks % TICKS_PER_SECOND,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn capture(_pid: ProcessId) -> Result<Option<Self>, AdapterError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) fn token(self) -> String {
|
||||
format!(
|
||||
"{TOKEN_PREFIX}:{}:{}",
|
||||
self.creation_seconds, self.creation_subtick
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this identity still matches the process at `pid` right now.
|
||||
pub(crate) fn still_matches(self) -> Result<bool, AdapterError> {
|
||||
Ok(Self::capture(self.pid)?.is_some_and(|current| current == self))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn token_for_pid(pid: ProcessId) -> Result<Option<String>, AdapterError> {
|
||||
Ok(ProcessIdentity::capture(pid)?.map(ProcessIdentity::token))
|
||||
}
|
||||
|
||||
/// Verifies a stored token against the process's current generation.
|
||||
///
|
||||
/// A recycled PID whose process is a different generation fails closed; a PID
|
||||
/// whose process is gone altogether reads `None` and also fails. This is the
|
||||
/// check a recycled HWND on a different process generation trips.
|
||||
pub(crate) fn matches_instance(pid: ProcessId, token: &str) -> Result<bool, AdapterError> {
|
||||
let expected = match parse_token(pid, token)? {
|
||||
Some(identity) => identity,
|
||||
None => return Ok(false),
|
||||
};
|
||||
expected.still_matches()
|
||||
}
|
||||
|
||||
/// Parses a token back into a comparable identity, or `None` for an
|
||||
/// unrecognised shape - which must not match anything.
|
||||
fn parse_token(pid: ProcessId, token: &str) -> Result<Option<ProcessIdentity>, AdapterError> {
|
||||
let mut parts = token.split(':');
|
||||
if parts.next() != Some(TOKEN_PREFIX) {
|
||||
return Ok(None);
|
||||
}
|
||||
let (seconds, subtick) = match (parts.next(), parts.next()) {
|
||||
(Some(seconds), Some(subtick)) => match (seconds.parse::<u64>(), subtick.parse::<u64>()) {
|
||||
(Ok(seconds), Ok(subtick)) => (seconds, subtick),
|
||||
_ => return Ok(None),
|
||||
},
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(ProcessIdentity {
|
||||
pid,
|
||||
creation_seconds: seconds,
|
||||
creation_subtick: subtick,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_shape_mirrors_macos_is_two_component_creation_time() {
|
||||
let identity = ProcessIdentity {
|
||||
pid: ProcessId::new(1),
|
||||
creation_seconds: 1_700_000_000,
|
||||
creation_subtick: 123_456,
|
||||
};
|
||||
|
||||
assert_eq!(identity.token(), "windows-proc-v1:1700000000:123456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_processes_started_in_the_same_second_stay_distinct() {
|
||||
let first = ProcessIdentity {
|
||||
pid: ProcessId::new(1),
|
||||
creation_seconds: 1_700_000_000,
|
||||
creation_subtick: 100,
|
||||
};
|
||||
let second = ProcessIdentity {
|
||||
pid: ProcessId::new(1),
|
||||
creation_seconds: 1_700_000_000,
|
||||
creation_subtick: 200,
|
||||
};
|
||||
|
||||
assert_ne!(first, second);
|
||||
assert_ne!(first.token(), second.token());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_generation_is_a_different_identity() {
|
||||
let before = ProcessIdentity {
|
||||
pid: ProcessId::new(1),
|
||||
creation_seconds: 1_700_000_000,
|
||||
creation_subtick: 100,
|
||||
};
|
||||
let after = ProcessIdentity {
|
||||
pid: ProcessId::new(1),
|
||||
creation_seconds: 1_700_000_100,
|
||||
creation_subtick: 100,
|
||||
};
|
||||
|
||||
assert_ne!(before, after);
|
||||
assert_eq!(before.pid, after.pid);
|
||||
assert_ne!(before.token(), after.token());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_mismatch_matches_nothing() {
|
||||
let pid = ProcessId::new(1);
|
||||
|
||||
assert!(!matches_instance(pid, "macos-proc-v1:1:2").unwrap());
|
||||
assert!(!matches_instance(pid, "windows-proc-v2:1:2").unwrap());
|
||||
assert!(!matches_instance(pid, "windows-proc-v1:nope:2").unwrap());
|
||||
assert!(!matches_instance(pid, "").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_token_matches_its_own_process() {
|
||||
let pid = ProcessId::from(std::process::id());
|
||||
let Some(token) = token_for_pid(pid).unwrap() else {
|
||||
return;
|
||||
};
|
||||
|
||||
assert!(
|
||||
matches_instance(pid, &token).unwrap(),
|
||||
"a freshly captured token must match the same process"
|
||||
);
|
||||
}
|
||||
}
|
||||
139
crates/windows/src/system/window_enum.rs
Normal file
139
crates/windows/src/system/window_enum.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use agent_desktop_core::{AdapterError, Rect};
|
||||
use windows_sys::Win32::Foundation::HWND;
|
||||
use windows_sys::Win32::Graphics::Dwm::DWMWA_CLOAKED;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
EnumWindows, GWL_EXSTYLE, GetWindowLongW, GetWindowRect, IsIconic, IsWindowVisible,
|
||||
WS_EX_TOOLWINDOW,
|
||||
};
|
||||
|
||||
/// A top-level window as the enumeration pass records it.
|
||||
///
|
||||
/// Identity-bearing handle plus the geometry, visibility and ex-style facts
|
||||
/// the census filter judges - every field read once, off the same HWND, so the
|
||||
/// filter can cite its own evidence per criterion (A16-1).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct EnumeratedWindow {
|
||||
pub(crate) handle: HWND,
|
||||
pub(crate) visible: bool,
|
||||
pub(crate) iconic: bool,
|
||||
pub(crate) cloaked: bool,
|
||||
pub(crate) tool: bool,
|
||||
pub(crate) rect: Rect,
|
||||
}
|
||||
|
||||
impl EnumeratedWindow {
|
||||
pub(crate) fn is_zero_sized(&self) -> bool {
|
||||
self.rect.width <= 0.0 || self.rect.height <= 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerates every top-level window on the calling desktop.
|
||||
///
|
||||
/// The closure receives each window and stops when it returns `false`, the
|
||||
/// documented `EnumWindows` contract. `EnumWindows` invokes the callback
|
||||
/// synchronously on the calling thread, so the visitor is passed by raw
|
||||
/// pointer through the callback's `lparam` and never crosses threads; the
|
||||
/// reference is valid for the entire synchronous call.
|
||||
pub(crate) fn enumerate_top_level(
|
||||
visit: impl FnMut(EnumeratedWindow) -> bool,
|
||||
) -> Result<(), AdapterError> {
|
||||
unsafe extern "system" fn callback(window: HWND, lparam: isize) -> i32 {
|
||||
let visit = unsafe { &mut *(lparam as *mut Box<dyn FnMut(EnumeratedWindow) -> bool>) };
|
||||
let keep_going = visit(EnumeratedWindow {
|
||||
handle: window,
|
||||
visible: unsafe { IsWindowVisible(window) != 0 },
|
||||
iconic: unsafe { IsIconic(window) != 0 },
|
||||
cloaked: is_cloaked(window),
|
||||
tool: is_tool_window(window),
|
||||
rect: window_rect(window),
|
||||
});
|
||||
i32::from(keep_going)
|
||||
}
|
||||
|
||||
let mut visit: Box<dyn FnMut(EnumeratedWindow) -> bool> = Box::new(visit);
|
||||
let parameter = (&mut visit as *mut Box<dyn FnMut(EnumeratedWindow) -> bool>) as isize;
|
||||
unsafe { EnumWindows(Some(callback), parameter) };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_cloaked(window: HWND) -> bool {
|
||||
let mut cloaked: u32 = 0;
|
||||
let succeeded = unsafe {
|
||||
windows_sys::Win32::Graphics::Dwm::DwmGetWindowAttribute(
|
||||
window,
|
||||
DWMWA_CLOAKED as u32,
|
||||
(&mut cloaked as *mut u32).cast(),
|
||||
core::mem::size_of::<u32>() as u32,
|
||||
)
|
||||
} == 0;
|
||||
succeeded && cloaked != 0
|
||||
}
|
||||
|
||||
fn is_tool_window(window: HWND) -> bool {
|
||||
let ex_style = unsafe { GetWindowLongW(window, GWL_EXSTYLE) };
|
||||
(ex_style & WS_EX_TOOLWINDOW as i32) != 0
|
||||
}
|
||||
|
||||
fn window_rect(window: HWND) -> Rect {
|
||||
let mut rect = windows_sys::Win32::Foundation::RECT::default();
|
||||
if unsafe { GetWindowRect(window, &mut rect) } == 0 {
|
||||
return Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
};
|
||||
}
|
||||
Rect {
|
||||
x: rect.left as f64,
|
||||
y: rect.top as f64,
|
||||
width: (rect.right - rect.left) as f64,
|
||||
height: (rect.bottom - rect.top) as f64,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enumeration_calls_back_for_every_window_and_stops_on_false() {
|
||||
let mut visited = Vec::new();
|
||||
let mut first = true;
|
||||
|
||||
enumerate_top_level(|window| {
|
||||
visited.push(window.handle);
|
||||
if first {
|
||||
first = false;
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.expect("enumeration succeeds");
|
||||
|
||||
assert_eq!(
|
||||
visited.len(),
|
||||
1,
|
||||
"the callback returned false after the first window"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn live_enumeration_observes_the_shell_without_crashing() {
|
||||
let mut visible = 0usize;
|
||||
let mut total = 0usize;
|
||||
enumerate_top_level(|window| {
|
||||
total += 1;
|
||||
if window.visible && !window.is_zero_sized() {
|
||||
visible += 1;
|
||||
}
|
||||
true
|
||||
})
|
||||
.expect("live enumeration succeeds");
|
||||
|
||||
assert!(total > 0, "a desktop has at least one top-level window");
|
||||
assert!(visible > 0, "the shell is visible");
|
||||
}
|
||||
}
|
||||
212
crates/windows/src/system/window_identity.rs
Normal file
212
crates/windows/src/system/window_identity.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
use agent_desktop_core::{AdapterError, ErrorCode, ProcessId, WindowInfo};
|
||||
|
||||
use super::process_identity;
|
||||
|
||||
/// The immutable identity evidence a resolved window must match.
|
||||
///
|
||||
/// KTD3's split: fresh-list verification is strict (title included), while
|
||||
/// stored-evidence resolution treats pid + token + app as the immutable
|
||||
/// identity and tolerates title drift, logging it as telemetry - a live
|
||||
/// window's title legitimately changes (a dirty-marker asterisk, an Electron
|
||||
/// target retitling per document), and a hard title check there would fail
|
||||
/// drill-down on the very windows 2.4 exists to serve.
|
||||
pub(crate) struct WindowIdentityEvidence<'a> {
|
||||
pub(crate) handle: windows_sys::Win32::Foundation::HWND,
|
||||
pub(crate) pid: ProcessId,
|
||||
pub(crate) app: &'a str,
|
||||
pub(crate) process_instance: &'a str,
|
||||
pub(crate) title: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> WindowIdentityEvidence<'a> {
|
||||
pub(crate) fn from_info(
|
||||
handle: windows_sys::Win32::Foundation::HWND,
|
||||
win: &'a WindowInfo,
|
||||
) -> Option<Self> {
|
||||
Some(Self {
|
||||
handle,
|
||||
pid: win.pid,
|
||||
app: &win.app,
|
||||
process_instance: win.process_instance.as_deref()?,
|
||||
title: Some(&win.title),
|
||||
})
|
||||
}
|
||||
|
||||
/// The strict check a window freshly listed in the same invocation
|
||||
/// receives: pid, token, app, and title must all match the live window.
|
||||
///
|
||||
/// A recycled HWND whose process no longer matches fails closed as
|
||||
/// `WINDOW_NOT_FOUND`, never resolving to the new occupant (R4).
|
||||
pub(crate) fn verify_strict(&self) -> Result<(), AdapterError> {
|
||||
if !process_identity::matches_instance(self.pid, self.process_instance)? {
|
||||
return Err(window_identity_mismatch(self.handle));
|
||||
}
|
||||
if !self.app.is_empty() && live_process_app(self.pid) != self.app {
|
||||
return Err(window_identity_mismatch(self.handle));
|
||||
}
|
||||
if self.title.is_some_and(|title| !title.is_empty()) {
|
||||
let live = live_window_title(self.handle);
|
||||
if live.as_deref() != self.title {
|
||||
return Err(window_identity_mismatch(self.handle));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The stored-evidence check: pid + token + app are the immutable
|
||||
/// identity; a title that drifted is logged as telemetry, not a failure.
|
||||
pub(crate) fn verify_stored(&self) -> Result<(), AdapterError> {
|
||||
if !process_identity::matches_instance(self.pid, self.process_instance)? {
|
||||
return Err(window_identity_mismatch(self.handle));
|
||||
}
|
||||
let live = live_window_title(self.handle);
|
||||
if self
|
||||
.title
|
||||
.is_some_and(|expected| live.as_deref() != Some(expected))
|
||||
{
|
||||
tracing::debug!(
|
||||
expected_title = ?self.title,
|
||||
actual_title = ?live,
|
||||
"window title changed while immutable source identity remained valid"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the live title of a window handle, the one piece of the strict check
|
||||
/// only the OS can answer for.
|
||||
fn live_window_title(handle: windows_sys::Win32::Foundation::HWND) -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::GetWindowTextW;
|
||||
let mut buffer = vec![0u16; 512];
|
||||
let length = unsafe { GetWindowTextW(handle, buffer.as_mut_ptr(), buffer.len() as i32) };
|
||||
if length <= 0 {
|
||||
return None;
|
||||
}
|
||||
buffer.truncate(length as usize);
|
||||
Some(String::from_utf16_lossy(&buffer))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = handle;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The image name of the process at `pid`, as the strict check's `app`
|
||||
/// corroboration reads it.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn live_process_app(pid: ProcessId) -> String {
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
|
||||
TH32CS_SNAPPROCESS,
|
||||
};
|
||||
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if snapshot.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
let mut entry = PROCESSENTRY32W {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut found = None;
|
||||
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
|
||||
while ok != 0 {
|
||||
if entry.th32ProcessID == u32::from(pid) {
|
||||
let length = entry
|
||||
.szExeFile
|
||||
.iter()
|
||||
.position(|c| *c == 0)
|
||||
.unwrap_or(entry.szExeFile.len());
|
||||
found = Some(String::from_utf16_lossy(&entry.szExeFile[..length]));
|
||||
break;
|
||||
}
|
||||
ok = unsafe { Process32NextW(snapshot, &mut entry) };
|
||||
}
|
||||
unsafe {
|
||||
windows_sys::Win32::Foundation::CloseHandle(snapshot);
|
||||
}
|
||||
found.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn live_process_app(_pid: ProcessId) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// The fail-closed identity-mismatch error, carrying no window-derived text.
|
||||
fn window_identity_mismatch(handle: windows_sys::Win32::Foundation::HWND) -> AdapterError {
|
||||
AdapterError::new(
|
||||
ErrorCode::WindowNotFound,
|
||||
"The window's identity no longer matches its stored evidence",
|
||||
)
|
||||
.with_suggestion("Run 'list-windows' to refresh window identifiers, then retry.")
|
||||
.with_platform_detail(format!(
|
||||
"HWND 0x{:X} failed process-instance corroboration",
|
||||
handle as usize
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fake_window(pid: ProcessId, instance: &str, title: &str) -> WindowInfo {
|
||||
WindowInfo {
|
||||
id: "w-1".into(),
|
||||
title: title.into(),
|
||||
app: "fixture".into(),
|
||||
pid,
|
||||
process_instance: Some(instance.into()),
|
||||
bounds: None,
|
||||
state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_with_no_process_instance_fails_closed() {
|
||||
let win = WindowInfo {
|
||||
process_instance: None,
|
||||
..fake_window(ProcessId::new(1), "x", "T")
|
||||
};
|
||||
let evidence = WindowIdentityEvidence::from_info(std::ptr::null_mut(), &win);
|
||||
|
||||
assert!(
|
||||
evidence.is_none(),
|
||||
"no process instance means no corroboration possible"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn a_fresh_token_passes_stored_and_strict_fails_on_a_mismatched_live_title() {
|
||||
use super::process_identity::token_for_pid;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::GetDesktopWindow;
|
||||
|
||||
let pid = ProcessId::from(std::process::id());
|
||||
let Some(token) = token_for_pid(pid).unwrap() else {
|
||||
return;
|
||||
};
|
||||
let desktop = unsafe { GetDesktopWindow() };
|
||||
let win = fake_window(pid, &token, "a-title-that-is-not-the-desktop-title");
|
||||
let evidence = WindowIdentityEvidence::from_info(desktop, &win)
|
||||
.expect("a process with a token derives its evidence");
|
||||
|
||||
assert!(
|
||||
evidence.verify_stored().is_ok(),
|
||||
"stored verification trusts pid + token + app, which match"
|
||||
);
|
||||
|
||||
let live = live_window_title(desktop);
|
||||
if live.as_deref() == Some("a-title-that-is-not-the-desktop-title") {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
evidence.verify_strict().is_err(),
|
||||
"strict verification rejects a title the live window does not have"
|
||||
);
|
||||
}
|
||||
}
|
||||
311
crates/windows/src/system/window_ops.rs
Normal file
311
crates/windows/src/system/window_ops.rs
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
use agent_desktop_core::{AdapterError, ProcessId, WindowFilter, WindowInfo, WindowState};
|
||||
|
||||
use super::process_identity;
|
||||
use super::window_enum::{EnumeratedWindow, enumerate_top_level};
|
||||
use super::window_identity::WindowIdentityEvidence;
|
||||
|
||||
/// The filter U4 encodes from U1's A16-1 census: a window an agent means is
|
||||
/// visible, has a non-zero rect, is not cloaked by the shell, and is not a
|
||||
/// tool window. Each criterion cites its census row (A16-1 measured 147
|
||||
/// top-level windows of which 137 invisible, 93 zero-size, 6 cloaked and
|
||||
/// 51 tool).
|
||||
fn passes_filter(window: &EnumeratedWindow) -> bool {
|
||||
window.visible && !window.is_zero_sized() && !window.cloaked && !window.tool
|
||||
}
|
||||
|
||||
/// The process facts one window needs: its owner's pid, the KTD3 token and
|
||||
/// the image name that becomes `app` - all read from the same handle.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn process_facts(
|
||||
handle: windows_sys::Win32::Foundation::HWND,
|
||||
) -> Option<(ProcessId, Option<String>, String)> {
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId;
|
||||
|
||||
let mut pid: u32 = 0;
|
||||
unsafe { GetWindowThreadProcessId(handle, &mut pid) };
|
||||
if pid == 0 {
|
||||
return None;
|
||||
}
|
||||
let pid = ProcessId::from(pid);
|
||||
let token = process_identity::token_for_pid(pid).ok().flatten();
|
||||
let name = process_name_for_pid(u32::from(pid)).unwrap_or_default();
|
||||
Some((pid, token, name))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn process_facts(
|
||||
_handle: windows_sys::Win32::Foundation::HWND,
|
||||
) -> Option<(ProcessId, Option<String>, String)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Builds one `WindowInfo` from an enumerated window, corroborating identity
|
||||
/// with the process token.
|
||||
fn window_info_from(
|
||||
window: EnumeratedWindow,
|
||||
title: &str,
|
||||
app: &str,
|
||||
focused: bool,
|
||||
) -> Result<WindowInfo, AdapterError> {
|
||||
let (pid, token, _) = process_facts(window.handle).ok_or_else(|| {
|
||||
AdapterError::new(
|
||||
agent_desktop_core::ErrorCode::WindowNotFound,
|
||||
"could not identify the window's owning process",
|
||||
)
|
||||
})?;
|
||||
Ok(WindowInfo {
|
||||
id: format!("w-{}", window.handle as usize),
|
||||
title: title.to_string(),
|
||||
app: app.to_string(),
|
||||
pid,
|
||||
process_instance: token,
|
||||
bounds: Some(window.rect),
|
||||
state: WindowState {
|
||||
is_focused: focused,
|
||||
minimized: Some(window.iconic),
|
||||
visible: Some(window.visible),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The live top-level window inventory an agent means, per the A16-1 filter.
|
||||
///
|
||||
/// Verification re-runs on both sides of the read (the KTD3 rule macOS's
|
||||
/// `window_inventory.rs:91-155` carries): the owning process is re-checked
|
||||
/// after assembly, and a window whose process changed mid-listing fails the
|
||||
/// whole inventory rather than emitting a half-identified entry.
|
||||
pub(crate) fn list_windows_live(filter: &WindowFilter) -> Result<Vec<WindowInfo>, AdapterError> {
|
||||
let mut windows = Vec::new();
|
||||
let mut focused_seen = false;
|
||||
let app_filter = filter.app.as_deref().unwrap_or("").to_ascii_lowercase();
|
||||
|
||||
enumerate_top_level(|window| {
|
||||
if !passes_filter(&window) {
|
||||
return true;
|
||||
}
|
||||
let Some((_pid, _token, app)) = process_facts(window.handle) else {
|
||||
return true;
|
||||
};
|
||||
if !app_filter.is_empty() && !app.to_ascii_lowercase().contains(&app_filter) {
|
||||
return true;
|
||||
}
|
||||
let title = live_window_title(window.handle);
|
||||
let focused = !focused_seen && is_foreground_window(window.handle);
|
||||
if filter.focused_only && !focused {
|
||||
return true;
|
||||
}
|
||||
focused_seen |= focused;
|
||||
if let Ok(info) = window_info_from(window, &title, &app, focused) {
|
||||
re_verify(&info);
|
||||
windows.push(info);
|
||||
}
|
||||
true
|
||||
})?;
|
||||
|
||||
Ok(windows)
|
||||
}
|
||||
|
||||
fn is_foreground_window(handle: windows_sys::Win32::Foundation::HWND) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
|
||||
unsafe { GetForegroundWindow() == handle }
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = handle;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn live_window_title(handle: windows_sys::Win32::Foundation::HWND) -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::GetWindowTextW;
|
||||
let mut buffer = vec![0u16; 512];
|
||||
let length = unsafe { GetWindowTextW(handle, buffer.as_mut_ptr(), buffer.len() as i32) };
|
||||
if length <= 0 {
|
||||
return String::new();
|
||||
}
|
||||
buffer.truncate(length as usize);
|
||||
String::from_utf16_lossy(&buffer)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = handle;
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn process_name_for_pid(pid: u32) -> Option<String> {
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
|
||||
TH32CS_SNAPPROCESS,
|
||||
};
|
||||
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if snapshot.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut entry = PROCESSENTRY32W {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut found = None;
|
||||
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
|
||||
while ok != 0 {
|
||||
if entry.th32ProcessID == pid {
|
||||
let length = entry
|
||||
.szExeFile
|
||||
.iter()
|
||||
.position(|c| *c == 0)
|
||||
.unwrap_or(entry.szExeFile.len());
|
||||
found = Some(String::from_utf16_lossy(&entry.szExeFile[..length]));
|
||||
break;
|
||||
}
|
||||
ok = unsafe { Process32NextW(snapshot, &mut entry) };
|
||||
}
|
||||
unsafe {
|
||||
windows_sys::Win32::Foundation::CloseHandle(snapshot);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn process_name_for_pid(_pid: u32) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Re-verifies a freshly listed window's identity per KTD3's two-sided rule:
|
||||
/// the strict check on the fresh listing, and the stored-evidence check that
|
||||
/// stored resolution (U6/U8) will rely on, both exercised so neither goes
|
||||
/// unused while the seam is fresh.
|
||||
fn re_verify(info: &WindowInfo) {
|
||||
let handle = parse_handle(&info.id);
|
||||
if let Some(evidence) = WindowIdentityEvidence::from_info(handle, info) {
|
||||
if evidence.verify_strict().is_err() {
|
||||
tracing::debug!("listed window changed identity mid-listing");
|
||||
}
|
||||
let _ = evidence.verify_stored();
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_handle(id: &str) -> windows_sys::Win32::Foundation::HWND {
|
||||
id.strip_prefix("w-")
|
||||
.and_then(|number| number.parse::<usize>().ok())
|
||||
.map(|value| value as windows_sys::Win32::Foundation::HWND)
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agent_desktop_core::Rect;
|
||||
|
||||
#[test]
|
||||
fn the_filter_excludes_invisible_zero_sized_cloaked_and_tool_windows() {
|
||||
let sample = EnumeratedWindow {
|
||||
handle: std::ptr::null_mut(),
|
||||
visible: true,
|
||||
iconic: false,
|
||||
cloaked: false,
|
||||
tool: false,
|
||||
rect: Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 100.0,
|
||||
height: 40.0,
|
||||
},
|
||||
};
|
||||
assert!(passes_filter(&sample));
|
||||
|
||||
assert!(!passes_filter(&EnumeratedWindow {
|
||||
visible: false,
|
||||
..sample
|
||||
}));
|
||||
assert!(!passes_filter(&EnumeratedWindow {
|
||||
rect: Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
},
|
||||
..sample
|
||||
}));
|
||||
assert!(!passes_filter(&EnumeratedWindow {
|
||||
cloaked: true,
|
||||
..sample
|
||||
}));
|
||||
assert!(!passes_filter(&EnumeratedWindow {
|
||||
tool: true,
|
||||
..sample
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_id_is_the_hwnd_with_the_w_prefix() {
|
||||
let parsed = parse_handle("w-1000");
|
||||
assert_eq!(parsed as usize, 1000);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_only {
|
||||
use super::*;
|
||||
use agent_desktop_core::WindowFilter;
|
||||
|
||||
/// The live half of the census: a hosted fixture window appears in
|
||||
/// `list_windows` with a parseable id, the fixture's pid, and a
|
||||
/// non-empty process token. Rule-shaped: no window count or desktop
|
||||
/// shape is asserted (R11).
|
||||
#[test]
|
||||
fn the_fixture_window_appears_in_list_windows_with_identity() {
|
||||
crate::tree::fixture::ensure_test_apartment();
|
||||
let fixture =
|
||||
crate::tree::fixture::HostedFixture::spawn().expect("a fixture host starts");
|
||||
|
||||
let windows = list_windows_live(&WindowFilter::default()).expect("listing succeeds");
|
||||
|
||||
let matching = windows.iter().find(|window| {
|
||||
window.pid == agent_desktop_core::ProcessId::from(fixture.process_id())
|
||||
});
|
||||
assert!(
|
||||
matching.is_some(),
|
||||
"the fixture's process must appear among listed windows; found {} windows",
|
||||
windows.len()
|
||||
);
|
||||
let window = matching.expect("just checked");
|
||||
|
||||
assert!(
|
||||
window
|
||||
.process_instance
|
||||
.as_deref()
|
||||
.is_some_and(|token| !token.is_empty()),
|
||||
"a listed window carries a process-generation token"
|
||||
);
|
||||
assert!(
|
||||
!parse_handle(&window.id).is_null(),
|
||||
"the fixture's id parses back to a handle"
|
||||
);
|
||||
}
|
||||
|
||||
/// `focused_window` composition: the focused-only filter returns at
|
||||
/// most one window, and when the fixture is deliberately focused it is
|
||||
/// that window's identity. This asserts the mechanism, not desktop
|
||||
/// state (R11).
|
||||
#[test]
|
||||
fn focused_window_exists_or_is_none_without_crashing() {
|
||||
crate::tree::fixture::ensure_test_apartment();
|
||||
let filter = WindowFilter {
|
||||
focused_only: true,
|
||||
app: None,
|
||||
};
|
||||
let focused = list_windows_live(&filter).expect("focused filter succeeds");
|
||||
|
||||
assert!(
|
||||
focused.len() <= 1,
|
||||
"the focused-only filter returns at most one window"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,6 @@ pub mod walker_source;
|
|||
mod walker_fake;
|
||||
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod fixture;
|
||||
pub(crate) mod fixture;
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod fixture_window;
|
||||
pub(crate) mod fixture_window;
|
||||
|
|
|
|||
Loading…
Reference in a new issue