1#[cfg(all(feature = "rich-content", target_os = "windows"))]
2use crate::rich_convert::plain_text_to_minimal_rtf;
3use crate::traits::{CapturePlatform, MonitorPlatform};
4use crate::types::{ActiveApp, CGRect, CaptureMethod, CleanupStatus, PlatformAttemptResult};
5#[cfg(target_os = "windows")]
6use crate::types::{CGPoint, CGSize};
7use crate::windows_observer::{
8 drain_events_for_monitor as windows_observer_drain_events_for_monitor, WindowsObserverBridge,
9};
10use crate::windows_runtime_adapter::install_default_windows_runtime_adapter_if_absent;
11use crate::windows_subscriber::ensure_windows_native_subscriber_hook_installed;
12use std::collections::VecDeque;
13#[cfg(target_os = "windows")]
14use std::process::Command;
15use std::sync::Mutex;
16use std::time::Duration;
17
18#[derive(Debug, Default)]
19pub struct WindowsPlatform;
20
21pub struct WindowsSelectionMonitor {
22 last_emitted: Mutex<Option<String>>,
23 native_event_queue: Mutex<VecDeque<String>>,
24 native_events_dropped: Mutex<u64>,
25 native_queue_capacity: usize,
26 poll_interval: Duration,
27 backend: WindowsMonitorBackend,
28 native_observer_attached: bool,
29 native_event_pump: Option<WindowsNativeEventPump>,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum WindowsMonitorBackend {
34 Polling,
35 NativeEventPreferred,
36}
37
38#[derive(Clone, Copy, Debug)]
39pub struct WindowsSelectionMonitorOptions {
40 pub poll_interval: Duration,
41 pub backend: WindowsMonitorBackend,
42 pub native_queue_capacity: usize,
43 pub native_event_pump: Option<WindowsNativeEventPump>,
44}
45
46pub type WindowsNativeEventPump = fn() -> Vec<String>;
47
48trait WindowsBackend {
49 fn attempt_ui_automation(&self) -> PlatformAttemptResult;
50 fn attempt_iaccessible(&self) -> PlatformAttemptResult;
51 fn attempt_clipboard(&self) -> PlatformAttemptResult;
52 fn attempt_synthetic_copy(&self) -> PlatformAttemptResult;
53}
54
55#[derive(Debug, Default)]
56struct DefaultWindowsBackend;
57
58impl WindowsBackend for DefaultWindowsBackend {
59 fn attempt_ui_automation(&self) -> PlatformAttemptResult {
60 #[cfg(target_os = "windows")]
61 {
62 match read_uia_text() {
63 Ok(Some(text)) => {
64 let trimmed = text.trim();
65 if trimmed.is_empty() {
66 PlatformAttemptResult::EmptySelection
67 } else {
68 PlatformAttemptResult::Success(trimmed.to_string())
69 }
70 }
71 Ok(None) => PlatformAttemptResult::EmptySelection,
72 Err(_) => PlatformAttemptResult::Unavailable,
73 }
74 }
75 #[cfg(not(target_os = "windows"))]
76 {
77 PlatformAttemptResult::Unavailable
78 }
79 }
80
81 fn attempt_iaccessible(&self) -> PlatformAttemptResult {
82 #[cfg(target_os = "windows")]
83 {
84 match read_iaccessible_text() {
85 Ok(Some(text)) => {
86 let trimmed = text.trim();
87 if trimmed.is_empty() {
88 PlatformAttemptResult::EmptySelection
89 } else {
90 PlatformAttemptResult::Success(trimmed.to_string())
91 }
92 }
93 Ok(None) => PlatformAttemptResult::EmptySelection,
94 Err(_) => PlatformAttemptResult::Unavailable,
95 }
96 }
97 #[cfg(not(target_os = "windows"))]
98 {
99 PlatformAttemptResult::Unavailable
100 }
101 }
102
103 fn attempt_clipboard(&self) -> PlatformAttemptResult {
104 #[cfg(target_os = "windows")]
105 {
106 match read_clipboard_text() {
107 Ok(Some(text)) => {
108 let trimmed = text.trim();
109 if trimmed.is_empty() {
110 PlatformAttemptResult::EmptySelection
111 } else {
112 PlatformAttemptResult::Success(trimmed.to_string())
113 }
114 }
115 Ok(None) => PlatformAttemptResult::EmptySelection,
116 Err(_) => PlatformAttemptResult::Unavailable,
117 }
118 }
119 #[cfg(not(target_os = "windows"))]
120 {
121 PlatformAttemptResult::Unavailable
122 }
123 }
124
125 fn attempt_synthetic_copy(&self) -> PlatformAttemptResult {
126 #[cfg(target_os = "windows")]
127 {
128 match synthetic_copy_capture_text() {
129 Ok(Some(text)) => {
130 let trimmed = text.trim();
131 if trimmed.is_empty() {
132 PlatformAttemptResult::EmptySelection
133 } else {
134 PlatformAttemptResult::Success(trimmed.to_string())
135 }
136 }
137 Ok(None) => PlatformAttemptResult::EmptySelection,
138 Err(_) => PlatformAttemptResult::Unavailable,
139 }
140 }
141 #[cfg(not(target_os = "windows"))]
142 {
143 PlatformAttemptResult::Unavailable
144 }
145 }
146}
147
148impl WindowsPlatform {
149 pub fn new() -> Self {
150 Self
151 }
152
153 pub fn attempt_ui_automation(&self) -> PlatformAttemptResult {
154 self.backend().attempt_ui_automation()
155 }
156
157 pub fn attempt_iaccessible(&self) -> PlatformAttemptResult {
158 self.backend().attempt_iaccessible()
159 }
160
161 pub fn attempt_clipboard(&self) -> PlatformAttemptResult {
162 self.backend().attempt_clipboard()
163 }
164
165 fn backend(&self) -> DefaultWindowsBackend {
166 DefaultWindowsBackend
167 }
168
169 fn dispatch_attempt<B: WindowsBackend>(
170 backend: &B,
171 method: CaptureMethod,
172 ) -> PlatformAttemptResult {
173 match method {
174 CaptureMethod::AccessibilityPrimary => backend.attempt_ui_automation(),
175 CaptureMethod::AccessibilityRange => backend.attempt_iaccessible(),
176 CaptureMethod::ClipboardBorrow => backend.attempt_clipboard(),
177 CaptureMethod::SyntheticCopy => backend.attempt_synthetic_copy(),
178 }
179 }
180}
181
182impl Default for WindowsSelectionMonitor {
183 fn default() -> Self {
184 Self::new_with_options(WindowsSelectionMonitorOptions::default())
185 }
186}
187
188impl Default for WindowsSelectionMonitorOptions {
189 fn default() -> Self {
190 Self {
191 poll_interval: Duration::from_millis(120),
192 backend: WindowsMonitorBackend::Polling,
193 native_queue_capacity: 256,
194 native_event_pump: None,
195 }
196 }
197}
198
199impl WindowsSelectionMonitor {
200 pub fn new(poll_interval: Duration) -> Self {
201 Self::new_with_options(WindowsSelectionMonitorOptions {
202 poll_interval,
203 backend: WindowsMonitorBackend::Polling,
204 native_queue_capacity: 256,
205 native_event_pump: None,
206 })
207 }
208
209 pub fn new_with_options(options: WindowsSelectionMonitorOptions) -> Self {
210 if matches!(options.backend, WindowsMonitorBackend::NativeEventPreferred) {
211 install_default_windows_runtime_adapter_if_absent();
212 ensure_windows_native_subscriber_hook_installed();
213 }
214 let native_observer_attached =
215 matches!(options.backend, WindowsMonitorBackend::NativeEventPreferred)
216 && WindowsObserverBridge::acquire();
217 let native_event_pump = if native_observer_attached {
218 options
219 .native_event_pump
220 .or(Some(windows_observer_drain_events_for_monitor))
221 } else {
222 options.native_event_pump
223 };
224
225 Self {
226 last_emitted: Mutex::new(None),
227 native_event_queue: Mutex::new(VecDeque::new()),
228 native_events_dropped: Mutex::new(0),
229 native_queue_capacity: options.native_queue_capacity.max(1),
230 poll_interval: options.poll_interval,
231 backend: options.backend,
232 native_observer_attached,
233 native_event_pump,
234 }
235 }
236
237 pub fn backend(&self) -> WindowsMonitorBackend {
238 self.backend
239 }
240
241 pub fn poll_interval(&self) -> Duration {
242 self.poll_interval
243 }
244
245 pub fn enqueue_native_selection_event<T>(&self, text: T) -> bool
246 where
247 T: Into<String>,
248 {
249 let text = text.into();
250 let trimmed = text.trim();
251 if trimmed.is_empty() {
252 return false;
253 }
254 if let Ok(mut queue) = self.native_event_queue.lock() {
255 if queue.back().map(|s| s == trimmed).unwrap_or(false) {
256 return false;
257 }
258 if queue.len() >= self.native_queue_capacity {
259 queue.pop_front();
260 if let Ok(mut dropped) = self.native_events_dropped.lock() {
261 *dropped += 1;
262 }
263 }
264 queue.push_back(trimmed.to_string());
265 return true;
266 }
267 false
268 }
269
270 pub fn enqueue_native_selection_events<I, T>(&self, events: I) -> usize
271 where
272 I: IntoIterator<Item = T>,
273 T: Into<String>,
274 {
275 let mut accepted = 0usize;
276 for event in events {
277 if self.enqueue_native_selection_event(event.into()) {
278 accepted += 1;
279 }
280 }
281 accepted
282 }
283
284 pub fn native_queue_depth(&self) -> usize {
285 self.native_event_queue
286 .lock()
287 .map(|queue| queue.len())
288 .unwrap_or(0)
289 }
290
291 pub fn native_events_dropped(&self) -> u64 {
292 self.native_events_dropped
293 .lock()
294 .map(|dropped| *dropped)
295 .unwrap_or(0)
296 }
297
298 pub fn poll_native_event_pump_once(&self) -> usize {
299 let Some(pump) = self.native_event_pump else {
300 return 0;
301 };
302 self.enqueue_native_selection_events(pump())
303 }
304
305 fn next_selection_text(&self) -> Option<String> {
306 if matches!(self.backend, WindowsMonitorBackend::NativeEventPreferred) {
307 let _ = self.poll_native_event_pump_once();
308 if let Some(next) = self.native_event_queue.lock().ok()?.pop_front() {
309 return self.emit_if_new(next);
310 }
311 }
312 let next = self.read_selection_text()?;
313 self.emit_if_new(next)
314 }
315
316 fn emit_if_new(&self, next: String) -> Option<String> {
317 let mut last = self.last_emitted.lock().ok()?;
318 if last.as_ref() == Some(&next) {
319 return None;
320 }
321 *last = Some(next.clone());
322 Some(next)
323 }
324
325 fn read_selection_text(&self) -> Option<String> {
326 #[cfg(target_os = "windows")]
327 {
328 let atspi = read_uia_text().ok().flatten();
329 if let Some(next) = atspi {
330 let trimmed = next.trim();
331 if !trimmed.is_empty() {
332 return Some(trimmed.to_string());
333 }
334 }
335
336 let legacy = read_iaccessible_text().ok().flatten();
337 if let Some(next) = legacy {
338 let trimmed = next.trim();
339 if !trimmed.is_empty() {
340 return Some(trimmed.to_string());
341 }
342 }
343 None
344 }
345 #[cfg(not(target_os = "windows"))]
346 {
347 None
348 }
349 }
350}
351
352impl CapturePlatform for WindowsPlatform {
353 fn active_app(&self) -> Option<ActiveApp> {
354 #[cfg(target_os = "windows")]
355 {
356 return read_active_app().ok().flatten();
357 }
358 #[cfg(not(target_os = "windows"))]
359 {
360 None
361 }
362 }
363
364 fn focused_window_frame(&self) -> Option<CGRect> {
365 #[cfg(target_os = "windows")]
366 {
367 return read_focused_window_frame().ok().flatten();
368 }
369 #[cfg(not(target_os = "windows"))]
370 {
371 None
372 }
373 }
374
375 fn attempt(&self, method: CaptureMethod, _app: Option<&ActiveApp>) -> PlatformAttemptResult {
376 Self::dispatch_attempt(&self.backend(), method)
377 }
378
379 fn cleanup(&self) -> CleanupStatus {
380 CleanupStatus::Clean
381 }
382}
383
384impl MonitorPlatform for WindowsSelectionMonitor {
385 fn next_selection_change(&self) -> Option<String> {
386 self.next_selection_text()
387 }
388}
389
390impl Drop for WindowsSelectionMonitor {
391 fn drop(&mut self) {
392 if self.native_observer_attached {
393 let _ = WindowsObserverBridge::release();
394 }
395 }
396}
397
398#[cfg(target_os = "windows")]
399fn read_clipboard_text() -> Result<Option<String>, String> {
400 let output = Command::new("powershell")
401 .args([
402 "-NoProfile",
403 "-NonInteractive",
404 "-Command",
405 "$t = Get-Clipboard -Raw; if ($null -eq $t) { '' } else { $t }",
406 ])
407 .output()
408 .map_err(|err| err.to_string())?;
409
410 if !output.status.success() {
411 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
412 }
413
414 let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
415 Ok(normalize_windows_text_stdout(&stdout))
416}
417
418#[cfg(target_os = "windows")]
419fn read_uia_text() -> Result<Option<String>, String> {
420 let output = Command::new("powershell")
421 .args([
422 "-NoProfile",
423 "-NonInteractive",
424 "-Command",
425 r#"
426Add-Type -AssemblyName UIAutomationClient
427Add-Type -AssemblyName UIAutomationTypes
428$focused = [System.Windows.Automation.AutomationElement]::FocusedElement
429if ($null -eq $focused) { return }
430try {
431 $textPattern = $focused.GetCurrentPattern([System.Windows.Automation.TextPattern]::Pattern)
432} catch {
433 $textPattern = $null
434}
435if ($null -ne $textPattern) {
436 $selection = $textPattern.GetSelection()
437 if ($null -ne $selection -and $selection.Length -gt 0) {
438 $text = $selection[0].GetText(-1)
439 if ($null -ne $text -and $text.Trim().Length -gt 0) {
440 Write-Output $text
441 return
442 }
443 }
444}
445try {
446 $valuePattern = $focused.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
447} catch {
448 $valuePattern = $null
449}
450if ($null -ne $valuePattern) {
451 $value = $valuePattern.Current.Value
452 if ($null -ne $value -and $value.Trim().Length -gt 0) {
453 Write-Output $value
454 return
455 }
456}
457"#,
458 ])
459 .output()
460 .map_err(|err| err.to_string())?;
461
462 if !output.status.success() {
463 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
464 }
465
466 let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
467 Ok(normalize_windows_text_stdout(&stdout))
468}
469
470#[cfg(all(feature = "rich-content", target_os = "windows"))]
471pub(crate) fn try_selected_rtf_by_uia() -> Option<String> {
472 let text = read_uia_text().ok().flatten()?;
473 let trimmed = text.trim();
474 if trimmed.is_empty() {
475 None
476 } else {
477 Some(plain_text_to_minimal_rtf(trimmed))
478 }
479}
480
481pub(crate) fn windows_default_runtime_event_source() -> Option<String> {
482 #[cfg(target_os = "windows")]
483 {
484 return read_uia_text().ok().flatten();
485 }
486 #[cfg(not(target_os = "windows"))]
487 {
488 None
489 }
490}
491
492#[cfg(target_os = "windows")]
493fn synthetic_copy_capture_text() -> Result<Option<String>, String> {
494 let output = Command::new("powershell")
495 .args([
496 "-NoProfile",
497 "-NonInteractive",
498 "-STA",
499 "-Command",
500 r#"
501Add-Type -AssemblyName System.Windows.Forms
502Add-Type -TypeDefinition @"
503using System;
504using System.Runtime.InteropServices;
505public static class Win32 {
506 [DllImport("user32.dll")]
507 public static extern IntPtr GetForegroundWindow();
508}
509"@
510$hwnd = [Win32]::GetForegroundWindow()
511if ($hwnd -eq [IntPtr]::Zero) { return }
512
513$original = $null
514$hasOriginal = $false
515try {
516 $original = Get-Clipboard -Raw -ErrorAction Stop
517 $hasOriginal = $true
518} catch {}
519
520[System.Windows.Forms.SendKeys]::SendWait("^c")
521Start-Sleep -Milliseconds 90
522
523$captured = $null
524try {
525 $captured = Get-Clipboard -Raw -ErrorAction Stop
526} catch {}
527
528if ($hasOriginal) {
529 try {
530 Set-Clipboard -Value $original
531 } catch {}
532}
533
534if ($null -ne $captured) {
535 Write-Output $captured
536}
537"#,
538 ])
539 .output()
540 .map_err(|err| err.to_string())?;
541
542 if !output.status.success() {
543 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
544 }
545
546 let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
547 Ok(normalize_windows_text_stdout(&stdout))
548}
549
550#[cfg(target_os = "windows")]
551fn read_iaccessible_text() -> Result<Option<String>, String> {
552 let output = Command::new("powershell")
553 .args([
554 "-NoProfile",
555 "-NonInteractive",
556 "-Command",
557 r#"
558Add-Type -AssemblyName UIAutomationClient
559Add-Type -AssemblyName UIAutomationTypes
560$focused = [System.Windows.Automation.AutomationElement]::FocusedElement
561if ($null -eq $focused) { return }
562try {
563 $legacy = $focused.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePattern]::Pattern)
564} catch {
565 $legacy = $null
566}
567if ($null -eq $legacy) { return }
568$value = $legacy.Current.Value
569if ($null -ne $value -and $value.Trim().Length -gt 0) {
570 Write-Output $value
571 return
572}
573$name = $legacy.Current.Name
574if ($null -ne $name -and $name.Trim().Length -gt 0) {
575 Write-Output $name
576 return
577}
578"#,
579 ])
580 .output()
581 .map_err(|err| err.to_string())?;
582
583 if !output.status.success() {
584 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
585 }
586
587 let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
588 Ok(normalize_windows_text_stdout(&stdout))
589}
590
591#[cfg(target_os = "windows")]
592fn read_active_app() -> Result<Option<ActiveApp>, String> {
593 let output = Command::new("powershell")
594 .args([
595 "-NoProfile",
596 "-NonInteractive",
597 "-Command",
598 r#"
599Add-Type -TypeDefinition @"
600using System;
601using System.Runtime.InteropServices;
602public static class Win32 {
603 [DllImport("user32.dll")]
604 public static extern IntPtr GetForegroundWindow();
605
606 [DllImport("user32.dll")]
607 public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
608}
609"@
610$hwnd = [Win32]::GetForegroundWindow()
611if ($hwnd -eq [IntPtr]::Zero) { return }
612$pid = 0
613[Win32]::GetWindowThreadProcessId($hwnd, [ref]$pid) | Out-Null
614if ($pid -eq 0) { return }
615$process = Get-Process -Id $pid -ErrorAction SilentlyContinue
616if ($null -eq $process) { return }
617$name = $process.ProcessName
618$path = $process.Path
619Write-Output ("NAME:" + $name)
620Write-Output ("PATH:" + $path)
621"#,
622 ])
623 .output()
624 .map_err(|err| err.to_string())?;
625
626 if !output.status.success() {
627 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
628 }
629
630 let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
631 Ok(parse_active_app_stdout(&stdout))
632}
633
634#[cfg(target_os = "windows")]
635fn read_focused_window_frame() -> Result<Option<CGRect>, String> {
636 let script = r#"
637Add-Type -TypeDefinition @"
638using System;
639using System.Runtime.InteropServices;
640public class Win32 {
641 [DllImport("user32.dll")]
642 public static extern IntPtr GetForegroundWindow();
643 [DllImport("user32.dll")]
644 [return: MarshalAs(UnmanagedType.Bool)]
645 public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
646 [StructLayout(LayoutKind.Sequential)]
647 public struct RECT {
648 public int Left;
649 public int Top;
650 public int Right;
651 public int Bottom;
652 }
653}
654"@
655
656$hwnd = [Win32]::GetForegroundWindow()
657if ($hwnd -eq [IntPtr]::Zero) { return }
658
659$rect = New-Object Win32+RECT
660if (-not [Win32]::GetWindowRect($hwnd, [ref]$rect)) { return }
661
662$width = $rect.Right - $rect.Left
663$height = $rect.Bottom - $rect.Top
664if ($width -le 0 -or $height -le 0) { return }
665
666Write-Output "$($rect.Left),$($rect.Top),$width,$height"
667"#;
668
669 let output = Command::new("powershell")
670 .args([
671 "-NoProfile",
672 "-NonInteractive",
673 "-ExecutionPolicy",
674 "Bypass",
675 "-Command",
676 script,
677 ])
678 .output()
679 .map_err(|err| err.to_string())?;
680
681 if !output.status.success() {
682 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
683 }
684
685 let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
686 parse_windows_rect_line(&stdout)
687}
688
689#[cfg(target_os = "windows")]
690fn parse_windows_rect_line(stdout: &str) -> Result<Option<CGRect>, String> {
691 let line = stdout.trim();
692 if line.is_empty() {
693 return Ok(None);
694 }
695
696 let parts = line.split(',').map(str::trim).collect::<Vec<_>>();
697 if parts.len() != 4 {
698 return Ok(None);
699 }
700
701 let left = parts[0].parse::<f64>().map_err(|err| err.to_string())?;
702 let top = parts[1].parse::<f64>().map_err(|err| err.to_string())?;
703 let width = parts[2].parse::<f64>().map_err(|err| err.to_string())?;
704 let height = parts[3].parse::<f64>().map_err(|err| err.to_string())?;
705
706 if width <= 0.0 || height <= 0.0 {
707 return Ok(None);
708 }
709
710 Ok(Some(CGRect {
711 origin: CGPoint { x: left, y: top },
712 size: CGSize { width, height },
713 }))
714}
715
716#[cfg(target_os = "windows")]
717fn normalize_windows_text_stdout(stdout: &str) -> Option<String> {
718 let text = stdout.replace("\r\n", "\n");
719 let normalized = text.trim_end_matches(['\r', '\n']);
720 if normalized.is_empty() {
721 None
722 } else {
723 Some(normalized.to_string())
724 }
725}
726
727#[cfg(target_os = "windows")]
728fn parse_active_app_stdout(stdout: &str) -> Option<ActiveApp> {
729 let mut name: Option<String> = None;
730 let mut path: Option<String> = None;
731
732 for line in stdout.lines() {
733 if let Some(value) = line.strip_prefix("NAME:") {
734 let trimmed = value.trim();
735 if !trimmed.is_empty() {
736 name = Some(trimmed.to_string());
737 }
738 } else if let Some(value) = line.strip_prefix("PATH:") {
739 let trimmed = value.trim();
740 if !trimmed.is_empty() {
741 path = Some(trimmed.to_string());
742 }
743 }
744 }
745
746 let app_name = name?;
747 let bundle_id = path.unwrap_or_else(|| format!("process://{}", app_name.to_lowercase()));
748 Some(ActiveApp {
749 bundle_id,
750 name: app_name,
751 })
752}
753
754#[cfg(test)]
755#[path = "windows_tests.rs"]
756mod tests;