use serde::{Deserialize, Serialize};
pub use car_browser::models::{A11yNode, Bounds, Modifier};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WindowHandle {
pub pid: u32,
pub window_id: u64,
}
impl WindowHandle {
pub fn new(pid: u32, window_id: u64) -> Self {
Self { pid, window_id }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DisplayId(pub u64);
impl DisplayId {
pub const PRIMARY: DisplayId = DisplayId(0);
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct WindowFrame {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl WindowFrame {
pub fn contains_point(&self, px: f64, py: f64) -> bool {
px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
}
pub fn center(&self) -> (f64, f64) {
(self.x + self.width * 0.5, self.y + self.height * 0.5)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowInfo {
pub handle: WindowHandle,
pub title: String,
pub bundle_id: Option<String>,
pub owner_name: String,
pub frame: WindowFrame,
pub layer: i32,
pub on_screen: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowFilter {
#[serde(default)]
pub pid: Option<u32>,
#[serde(default)]
pub bundle_id: Option<String>,
#[serde(default)]
pub title_contains: Option<String>,
#[serde(default = "default_visible_only")]
pub visible_only: bool,
}
fn default_visible_only() -> bool {
true
}
impl Default for WindowFilter {
fn default() -> Self {
Self {
pid: None,
bundle_id: None,
title_contains: None,
visible_only: true,
}
}
}
impl WindowFilter {
pub fn by_pid(pid: u32) -> Self {
Self {
pid: Some(pid),
visible_only: true,
..Self::default()
}
}
pub fn by_bundle_id(bundle: impl Into<String>) -> Self {
Self {
bundle_id: Some(bundle.into()),
visible_only: true,
..Self::default()
}
}
pub fn by_title_contains(needle: impl Into<String>) -> Self {
Self {
title_contains: Some(needle.into()),
visible_only: true,
..Self::default()
}
}
pub fn matches(&self, info: &WindowInfo) -> bool {
if self.visible_only && !info.on_screen {
return false;
}
if let Some(p) = self.pid {
if info.handle.pid != p {
return false;
}
}
if let Some(b) = &self.bundle_id {
match &info.bundle_id {
Some(bid) if bid == b => {}
_ => return false,
}
}
if let Some(needle) = &self.title_contains {
if !info.title.contains(needle.as_str()) {
return false;
}
}
true
}
}
#[derive(Debug, Clone)]
pub struct Frame {
pub width: u32,
pub height: u32,
pub scale_factor: f32,
pub rgba: Vec<u8>,
pub captured_at: chrono::DateTime<chrono::Utc>,
}
impl Frame {
pub fn validate(&self) -> Result<(), String> {
let expected = (self.width as usize)
.saturating_mul(self.height as usize)
.saturating_mul(4);
if self.rgba.len() != expected {
return Err(format!(
"Frame byte length {} does not match {}x{}x4 = {}",
self.rgba.len(),
self.width,
self.height,
expected
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct A11yElementRecord {
pub bounds: Bounds,
pub role: String,
pub name: Option<String>,
pub value: Option<String>,
pub focusable: bool,
pub focused: bool,
pub disabled: bool,
}
#[derive(Debug, Clone)]
pub struct UiMap {
pub window: WindowInfo,
pub frame: Option<Frame>,
pub a11y_root: Option<A11yNode>,
pub a11y_index: Vec<String>,
pub a11y_by_id: std::collections::HashMap<String, A11yElementRecord>,
pub a11y_truncated: bool,
pub a11y_empty: bool,
pub observed_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClickRequest {
pub window: WindowHandle,
pub element_id: Option<String>,
pub point: Option<(f64, f64)>,
#[serde(default)]
pub button: MouseButton,
#[serde(default)]
pub modifiers: Vec<Modifier>,
#[serde(default)]
pub unsafe_ok: bool,
#[serde(default)]
pub dry_run: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MouseButton {
#[default]
Left,
Right,
Middle,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypeRequest {
pub window: WindowHandle,
pub text: String,
#[serde(default)]
pub dry_run: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyPressRequest {
pub window: WindowHandle,
pub key: Key,
#[serde(default)]
pub modifiers: Vec<Modifier>,
#[serde(default)]
pub dry_run: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum Key {
Return,
Escape,
Tab,
Space,
Backspace,
Delete,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
Home,
End,
PageUp,
PageDown,
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
Char(char),
Comma,
Period,
Slash,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionSnapshot {
pub screen_recording: bool,
pub accessibility: bool,
pub needs_restart: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct PermissionRequest {
pub screen_recording: bool,
pub accessibility: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_frame_contains_and_center() {
let f = WindowFrame {
x: 10.0,
y: 20.0,
width: 100.0,
height: 50.0,
};
assert!(f.contains_point(50.0, 40.0));
assert!(!f.contains_point(5.0, 40.0));
assert!(!f.contains_point(200.0, 40.0));
let (cx, cy) = f.center();
assert!((cx - 60.0).abs() < 1e-6);
assert!((cy - 45.0).abs() < 1e-6);
}
fn sample_info(title: &str, pid: u32, bundle: Option<&str>, on_screen: bool) -> WindowInfo {
WindowInfo {
handle: WindowHandle::new(pid, 1),
title: title.into(),
bundle_id: bundle.map(|s| s.into()),
owner_name: "test".into(),
frame: WindowFrame {
x: 0.0,
y: 0.0,
width: 800.0,
height: 600.0,
},
layer: 0,
on_screen,
}
}
#[test]
fn filter_default_is_visible_only() {
let f = WindowFilter::default();
assert!(f.visible_only);
assert!(f.matches(&sample_info("x", 1, None, true)));
assert!(!f.matches(&sample_info("x", 1, None, false)));
}
#[test]
fn filter_by_pid_and_bundle_conjunctive() {
let f = WindowFilter::by_bundle_id("ai.parslee.tokhn");
assert!(f.matches(&sample_info("Tokhn", 42, Some("ai.parslee.tokhn"), true)));
assert!(!f.matches(&sample_info("Tokhn", 42, Some("com.other"), true)));
assert!(!f.matches(&sample_info("Tokhn", 42, None, true)));
}
#[test]
fn filter_title_contains_matches_substring() {
let f = WindowFilter::by_title_contains("Settings");
assert!(f.matches(&sample_info("Tokhn Settings", 1, None, true)));
assert!(!f.matches(&sample_info("Tokhn", 1, None, true)));
}
#[test]
fn filter_may_bypass_visible_only() {
let mut f = WindowFilter::by_pid(1);
f.visible_only = false;
assert!(f.matches(&sample_info("x", 1, None, false)));
}
#[test]
fn frame_validate_rejects_size_mismatch() {
let f = Frame {
width: 2,
height: 2,
scale_factor: 1.0,
rgba: vec![0u8; 7], captured_at: chrono::Utc::now(),
};
assert!(f.validate().is_err());
}
#[test]
fn frame_validate_accepts_correct_size() {
let f = Frame {
width: 2,
height: 2,
scale_factor: 1.0,
rgba: vec![0u8; 16],
captured_at: chrono::Utc::now(),
};
assert!(f.validate().is_ok());
}
}