#[cfg(all(feature = "rich-content", target_os = "windows"))]
use crate::rich_convert::plain_text_to_minimal_rtf;
use crate::traits::{CapturePlatform, MonitorPlatform};
use crate::types::{ActiveApp, CGRect, CaptureMethod, CleanupStatus, PlatformAttemptResult};
#[cfg(target_os = "windows")]
use crate::types::{CGPoint, CGSize};
use crate::windows_observer::{
drain_events_for_monitor as windows_observer_drain_events_for_monitor, WindowsObserverBridge,
};
use crate::windows_runtime_adapter::install_default_windows_runtime_adapter_if_absent;
use crate::windows_subscriber::ensure_windows_native_subscriber_hook_installed;
use std::collections::VecDeque;
#[cfg(target_os = "windows")]
use std::process::Command;
use std::sync::Mutex;
use std::time::Duration;
#[derive(Debug, Default)]
pub struct WindowsPlatform;
pub struct WindowsSelectionMonitor {
last_emitted: Mutex<Option<String>>,
native_event_queue: Mutex<VecDeque<String>>,
native_events_dropped: Mutex<u64>,
native_queue_capacity: usize,
poll_interval: Duration,
backend: WindowsMonitorBackend,
native_observer_attached: bool,
native_event_pump: Option<WindowsNativeEventPump>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowsMonitorBackend {
Polling,
NativeEventPreferred,
}
#[derive(Clone, Copy, Debug)]
pub struct WindowsSelectionMonitorOptions {
pub poll_interval: Duration,
pub backend: WindowsMonitorBackend,
pub native_queue_capacity: usize,
pub native_event_pump: Option<WindowsNativeEventPump>,
}
pub type WindowsNativeEventPump = fn() -> Vec<String>;
trait WindowsBackend {
fn attempt_ui_automation(&self) -> PlatformAttemptResult;
fn attempt_iaccessible(&self) -> PlatformAttemptResult;
fn attempt_clipboard(&self) -> PlatformAttemptResult;
fn attempt_synthetic_copy(&self) -> PlatformAttemptResult;
}
#[derive(Debug, Default)]
struct DefaultWindowsBackend;
impl WindowsBackend for DefaultWindowsBackend {
fn attempt_ui_automation(&self) -> PlatformAttemptResult {
#[cfg(target_os = "windows")]
{
match read_uia_text() {
Ok(Some(text)) => {
let trimmed = text.trim();
if trimmed.is_empty() {
PlatformAttemptResult::EmptySelection
} else {
PlatformAttemptResult::Success(trimmed.to_string())
}
}
Ok(None) => PlatformAttemptResult::EmptySelection,
Err(_) => PlatformAttemptResult::Unavailable,
}
}
#[cfg(not(target_os = "windows"))]
{
PlatformAttemptResult::Unavailable
}
}
fn attempt_iaccessible(&self) -> PlatformAttemptResult {
#[cfg(target_os = "windows")]
{
match read_iaccessible_text() {
Ok(Some(text)) => {
let trimmed = text.trim();
if trimmed.is_empty() {
PlatformAttemptResult::EmptySelection
} else {
PlatformAttemptResult::Success(trimmed.to_string())
}
}
Ok(None) => PlatformAttemptResult::EmptySelection,
Err(_) => PlatformAttemptResult::Unavailable,
}
}
#[cfg(not(target_os = "windows"))]
{
PlatformAttemptResult::Unavailable
}
}
fn attempt_clipboard(&self) -> PlatformAttemptResult {
#[cfg(target_os = "windows")]
{
match read_clipboard_text() {
Ok(Some(text)) => {
let trimmed = text.trim();
if trimmed.is_empty() {
PlatformAttemptResult::EmptySelection
} else {
PlatformAttemptResult::Success(trimmed.to_string())
}
}
Ok(None) => PlatformAttemptResult::EmptySelection,
Err(_) => PlatformAttemptResult::Unavailable,
}
}
#[cfg(not(target_os = "windows"))]
{
PlatformAttemptResult::Unavailable
}
}
fn attempt_synthetic_copy(&self) -> PlatformAttemptResult {
#[cfg(target_os = "windows")]
{
match synthetic_copy_capture_text() {
Ok(Some(text)) => {
let trimmed = text.trim();
if trimmed.is_empty() {
PlatformAttemptResult::EmptySelection
} else {
PlatformAttemptResult::Success(trimmed.to_string())
}
}
Ok(None) => PlatformAttemptResult::EmptySelection,
Err(_) => PlatformAttemptResult::Unavailable,
}
}
#[cfg(not(target_os = "windows"))]
{
PlatformAttemptResult::Unavailable
}
}
}
impl WindowsPlatform {
pub fn new() -> Self {
Self
}
pub fn attempt_ui_automation(&self) -> PlatformAttemptResult {
self.backend().attempt_ui_automation()
}
pub fn attempt_iaccessible(&self) -> PlatformAttemptResult {
self.backend().attempt_iaccessible()
}
pub fn attempt_clipboard(&self) -> PlatformAttemptResult {
self.backend().attempt_clipboard()
}
fn backend(&self) -> DefaultWindowsBackend {
DefaultWindowsBackend
}
fn dispatch_attempt<B: WindowsBackend>(
backend: &B,
method: CaptureMethod,
) -> PlatformAttemptResult {
match method {
CaptureMethod::AccessibilityPrimary => backend.attempt_ui_automation(),
CaptureMethod::AccessibilityRange => backend.attempt_iaccessible(),
CaptureMethod::ClipboardBorrow => backend.attempt_clipboard(),
CaptureMethod::SyntheticCopy => backend.attempt_synthetic_copy(),
}
}
}
impl Default for WindowsSelectionMonitor {
fn default() -> Self {
Self::new_with_options(WindowsSelectionMonitorOptions::default())
}
}
impl Default for WindowsSelectionMonitorOptions {
fn default() -> Self {
Self {
poll_interval: Duration::from_millis(120),
backend: WindowsMonitorBackend::Polling,
native_queue_capacity: 256,
native_event_pump: None,
}
}
}
impl WindowsSelectionMonitor {
pub fn new(poll_interval: Duration) -> Self {
Self::new_with_options(WindowsSelectionMonitorOptions {
poll_interval,
backend: WindowsMonitorBackend::Polling,
native_queue_capacity: 256,
native_event_pump: None,
})
}
pub fn new_with_options(options: WindowsSelectionMonitorOptions) -> Self {
if matches!(options.backend, WindowsMonitorBackend::NativeEventPreferred) {
install_default_windows_runtime_adapter_if_absent();
ensure_windows_native_subscriber_hook_installed();
}
let native_observer_attached =
matches!(options.backend, WindowsMonitorBackend::NativeEventPreferred)
&& WindowsObserverBridge::acquire();
let native_event_pump = if native_observer_attached {
options
.native_event_pump
.or(Some(windows_observer_drain_events_for_monitor))
} else {
options.native_event_pump
};
Self {
last_emitted: Mutex::new(None),
native_event_queue: Mutex::new(VecDeque::new()),
native_events_dropped: Mutex::new(0),
native_queue_capacity: options.native_queue_capacity.max(1),
poll_interval: options.poll_interval,
backend: options.backend,
native_observer_attached,
native_event_pump,
}
}
pub fn backend(&self) -> WindowsMonitorBackend {
self.backend
}
pub fn poll_interval(&self) -> Duration {
self.poll_interval
}
pub fn enqueue_native_selection_event<T>(&self, text: T) -> bool
where
T: Into<String>,
{
let text = text.into();
let trimmed = text.trim();
if trimmed.is_empty() {
return false;
}
if let Ok(mut queue) = self.native_event_queue.lock() {
if queue.back().map(|s| s == trimmed).unwrap_or(false) {
return false;
}
if queue.len() >= self.native_queue_capacity {
queue.pop_front();
if let Ok(mut dropped) = self.native_events_dropped.lock() {
*dropped += 1;
}
}
queue.push_back(trimmed.to_string());
return true;
}
false
}
pub fn enqueue_native_selection_events<I, T>(&self, events: I) -> usize
where
I: IntoIterator<Item = T>,
T: Into<String>,
{
let mut accepted = 0usize;
for event in events {
if self.enqueue_native_selection_event(event.into()) {
accepted += 1;
}
}
accepted
}
pub fn native_queue_depth(&self) -> usize {
self.native_event_queue
.lock()
.map(|queue| queue.len())
.unwrap_or(0)
}
pub fn native_events_dropped(&self) -> u64 {
self.native_events_dropped
.lock()
.map(|dropped| *dropped)
.unwrap_or(0)
}
pub fn poll_native_event_pump_once(&self) -> usize {
let Some(pump) = self.native_event_pump else {
return 0;
};
self.enqueue_native_selection_events(pump())
}
fn next_selection_text(&self) -> Option<String> {
if matches!(self.backend, WindowsMonitorBackend::NativeEventPreferred) {
let _ = self.poll_native_event_pump_once();
if let Some(next) = self.native_event_queue.lock().ok()?.pop_front() {
return self.emit_if_new(next);
}
}
let next = self.read_selection_text()?;
self.emit_if_new(next)
}
fn emit_if_new(&self, next: String) -> Option<String> {
let mut last = self.last_emitted.lock().ok()?;
if last.as_ref() == Some(&next) {
return None;
}
*last = Some(next.clone());
Some(next)
}
fn read_selection_text(&self) -> Option<String> {
#[cfg(target_os = "windows")]
{
let atspi = read_uia_text().ok().flatten();
if let Some(next) = atspi {
let trimmed = next.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
let legacy = read_iaccessible_text().ok().flatten();
if let Some(next) = legacy {
let trimmed = next.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
None
}
#[cfg(not(target_os = "windows"))]
{
None
}
}
}
impl CapturePlatform for WindowsPlatform {
fn active_app(&self) -> Option<ActiveApp> {
#[cfg(target_os = "windows")]
{
return read_active_app().ok().flatten();
}
#[cfg(not(target_os = "windows"))]
{
None
}
}
fn focused_window_frame(&self) -> Option<CGRect> {
#[cfg(target_os = "windows")]
{
return read_focused_window_frame().ok().flatten();
}
#[cfg(not(target_os = "windows"))]
{
None
}
}
fn attempt(&self, method: CaptureMethod, _app: Option<&ActiveApp>) -> PlatformAttemptResult {
Self::dispatch_attempt(&self.backend(), method)
}
fn cleanup(&self) -> CleanupStatus {
CleanupStatus::Clean
}
}
impl MonitorPlatform for WindowsSelectionMonitor {
fn next_selection_change(&self) -> Option<String> {
self.next_selection_text()
}
}
impl Drop for WindowsSelectionMonitor {
fn drop(&mut self) {
if self.native_observer_attached {
let _ = WindowsObserverBridge::release();
}
}
}
#[cfg(target_os = "windows")]
fn read_clipboard_text() -> Result<Option<String>, String> {
let output = Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"$t = Get-Clipboard -Raw; if ($null -eq $t) { '' } else { $t }",
])
.output()
.map_err(|err| err.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
Ok(normalize_windows_text_stdout(&stdout))
}
#[cfg(target_os = "windows")]
fn read_uia_text() -> Result<Option<String>, String> {
let output = Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
r#"
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
$focused = [System.Windows.Automation.AutomationElement]::FocusedElement
if ($null -eq $focused) { return }
try {
$textPattern = $focused.GetCurrentPattern([System.Windows.Automation.TextPattern]::Pattern)
} catch {
$textPattern = $null
}
if ($null -ne $textPattern) {
$selection = $textPattern.GetSelection()
if ($null -ne $selection -and $selection.Length -gt 0) {
$text = $selection[0].GetText(-1)
if ($null -ne $text -and $text.Trim().Length -gt 0) {
Write-Output $text
return
}
}
}
try {
$valuePattern = $focused.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
} catch {
$valuePattern = $null
}
if ($null -ne $valuePattern) {
$value = $valuePattern.Current.Value
if ($null -ne $value -and $value.Trim().Length -gt 0) {
Write-Output $value
return
}
}
"#,
])
.output()
.map_err(|err| err.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
Ok(normalize_windows_text_stdout(&stdout))
}
#[cfg(all(feature = "rich-content", target_os = "windows"))]
pub(crate) fn try_selected_rtf_by_uia() -> Option<String> {
let text = read_uia_text().ok().flatten()?;
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(plain_text_to_minimal_rtf(trimmed))
}
}
pub(crate) fn windows_default_runtime_event_source() -> Option<String> {
#[cfg(target_os = "windows")]
{
return read_uia_text().ok().flatten();
}
#[cfg(not(target_os = "windows"))]
{
None
}
}
#[cfg(target_os = "windows")]
fn synthetic_copy_capture_text() -> Result<Option<String>, String> {
let output = Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-STA",
"-Command",
r#"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class Win32 {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
}
"@
$hwnd = [Win32]::GetForegroundWindow()
if ($hwnd -eq [IntPtr]::Zero) { return }
$original = $null
$hasOriginal = $false
try {
$original = Get-Clipboard -Raw -ErrorAction Stop
$hasOriginal = $true
} catch {}
[System.Windows.Forms.SendKeys]::SendWait("^c")
Start-Sleep -Milliseconds 90
$captured = $null
try {
$captured = Get-Clipboard -Raw -ErrorAction Stop
} catch {}
if ($hasOriginal) {
try {
Set-Clipboard -Value $original
} catch {}
}
if ($null -ne $captured) {
Write-Output $captured
}
"#,
])
.output()
.map_err(|err| err.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
Ok(normalize_windows_text_stdout(&stdout))
}
#[cfg(target_os = "windows")]
fn read_iaccessible_text() -> Result<Option<String>, String> {
let output = Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
r#"
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
$focused = [System.Windows.Automation.AutomationElement]::FocusedElement
if ($null -eq $focused) { return }
try {
$legacy = $focused.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePattern]::Pattern)
} catch {
$legacy = $null
}
if ($null -eq $legacy) { return }
$value = $legacy.Current.Value
if ($null -ne $value -and $value.Trim().Length -gt 0) {
Write-Output $value
return
}
$name = $legacy.Current.Name
if ($null -ne $name -and $name.Trim().Length -gt 0) {
Write-Output $name
return
}
"#,
])
.output()
.map_err(|err| err.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
Ok(normalize_windows_text_stdout(&stdout))
}
#[cfg(target_os = "windows")]
fn read_active_app() -> Result<Option<ActiveApp>, String> {
let output = Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
r#"
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class Win32 {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}
"@
$hwnd = [Win32]::GetForegroundWindow()
if ($hwnd -eq [IntPtr]::Zero) { return }
$pid = 0
[Win32]::GetWindowThreadProcessId($hwnd, [ref]$pid) | Out-Null
if ($pid -eq 0) { return }
$process = Get-Process -Id $pid -ErrorAction SilentlyContinue
if ($null -eq $process) { return }
$name = $process.ProcessName
$path = $process.Path
Write-Output ("NAME:" + $name)
Write-Output ("PATH:" + $path)
"#,
])
.output()
.map_err(|err| err.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
Ok(parse_active_app_stdout(&stdout))
}
#[cfg(target_os = "windows")]
fn read_focused_window_frame() -> Result<Option<CGRect>, String> {
let script = r#"
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class Win32 {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[StructLayout(LayoutKind.Sequential)]
public struct RECT {
public int Left;
public int Top;
public int Right;
public int Bottom;
}
}
"@
$hwnd = [Win32]::GetForegroundWindow()
if ($hwnd -eq [IntPtr]::Zero) { return }
$rect = New-Object Win32+RECT
if (-not [Win32]::GetWindowRect($hwnd, [ref]$rect)) { return }
$width = $rect.Right - $rect.Left
$height = $rect.Bottom - $rect.Top
if ($width -le 0 -or $height -le 0) { return }
Write-Output "$($rect.Left),$($rect.Top),$width,$height"
"#;
let output = Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
])
.output()
.map_err(|err| err.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
parse_windows_rect_line(&stdout)
}
#[cfg(target_os = "windows")]
fn parse_windows_rect_line(stdout: &str) -> Result<Option<CGRect>, String> {
let line = stdout.trim();
if line.is_empty() {
return Ok(None);
}
let parts = line.split(',').map(str::trim).collect::<Vec<_>>();
if parts.len() != 4 {
return Ok(None);
}
let left = parts[0].parse::<f64>().map_err(|err| err.to_string())?;
let top = parts[1].parse::<f64>().map_err(|err| err.to_string())?;
let width = parts[2].parse::<f64>().map_err(|err| err.to_string())?;
let height = parts[3].parse::<f64>().map_err(|err| err.to_string())?;
if width <= 0.0 || height <= 0.0 {
return Ok(None);
}
Ok(Some(CGRect {
origin: CGPoint { x: left, y: top },
size: CGSize { width, height },
}))
}
#[cfg(target_os = "windows")]
fn normalize_windows_text_stdout(stdout: &str) -> Option<String> {
let text = stdout.replace("\r\n", "\n");
let normalized = text.trim_end_matches(['\r', '\n']);
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
#[cfg(target_os = "windows")]
fn parse_active_app_stdout(stdout: &str) -> Option<ActiveApp> {
let mut name: Option<String> = None;
let mut path: Option<String> = None;
for line in stdout.lines() {
if let Some(value) = line.strip_prefix("NAME:") {
let trimmed = value.trim();
if !trimmed.is_empty() {
name = Some(trimmed.to_string());
}
} else if let Some(value) = line.strip_prefix("PATH:") {
let trimmed = value.trim();
if !trimmed.is_empty() {
path = Some(trimmed.to_string());
}
}
}
let app_name = name?;
let bundle_id = path.unwrap_or_else(|| format!("process://{}", app_name.to_lowercase()));
Some(ActiveApp {
bundle_id,
name: app_name,
})
}
#[cfg(test)]
#[path = "windows_tests.rs"]
mod tests;