Skip to main content

selection_capture/
windows.rs

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;