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