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, 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;