feat: add window identity, list_windows and focused_window on windows (sub-phase 2.4 U4)

This commit is contained in:
Lahfir 2026-08-02 06:13:58 -06:00
parent 69c6199b0f
commit 63506e69bb
10 changed files with 890 additions and 6 deletions

1
Cargo.lock generated
View file

@ -90,6 +90,7 @@ dependencies = [
"agent-desktop-core",
"serde_json",
"thiserror",
"tracing",
"uiautomation",
"windows",
"windows-sys",

View file

@ -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",
] }

View file

@ -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 {}

View file

@ -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,

View file

@ -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;

View 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"
);
}
}

View 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");
}
}

View 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"
);
}
}

View 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"
);
}
}
}

View file

@ -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;