winctx/window_loop/
clipboard_manager.rs

1use std::str;
2
3use tokio::sync::mpsc::UnboundedSender;
4use windows_sys::Win32::Foundation::HWND;
5use windows_sys::Win32::UI::WindowsAndMessaging as winuser;
6use windows_sys::Win32::UI::WindowsAndMessaging::MSG;
7
8use crate::clipboard::{Clipboard, ClipboardFormat};
9use crate::error::{ErrorKind, WindowError};
10use crate::event::ClipboardEvent;
11use crate::Error;
12
13use super::WindowEvent;
14
15const CLIPBOARD_RETRY_TIMER: usize = 1000;
16const RETRY_MILLIS: u32 = 25;
17const RETRY_MAX_ATTEMPTS: usize = 10;
18
19/// A timer used to debounce reacting to clipboard updates.
20///
21/// We will only process updates again after this timer has been fired.
22const CLIPBOARD_DEBOUNCE_TIMER: usize = 1001;
23const DEBOUNCE_MILLIS: u32 = 25;
24
25/// Helper to manager clipboard polling state.
26pub(super) struct ClipboardManager<'a> {
27    events_tx: &'a UnboundedSender<WindowEvent>,
28    attempts: usize,
29    supported: Option<ClipboardFormat>,
30}
31
32impl<'a> ClipboardManager<'a> {
33    pub(super) fn new(events_tx: &'a UnboundedSender<WindowEvent>) -> Self {
34        Self {
35            events_tx,
36            attempts: 0,
37            supported: None,
38        }
39    }
40
41    pub(super) unsafe fn dispatch(&mut self, msg: &MSG) -> bool {
42        match msg.message {
43            winuser::WM_CLIPBOARDUPDATE => {
44                // Debounce incoming events.
45                winuser::SetTimer(msg.hwnd, CLIPBOARD_DEBOUNCE_TIMER, DEBOUNCE_MILLIS, None);
46                true
47            }
48            winuser::WM_TIMER => match msg.wParam {
49                CLIPBOARD_RETRY_TIMER => {
50                    self.handle_timer(msg.hwnd);
51                    true
52                }
53                CLIPBOARD_DEBOUNCE_TIMER => {
54                    winuser::KillTimer(msg.hwnd, CLIPBOARD_DEBOUNCE_TIMER);
55                    self.populate_formats();
56
57                    // We need to incorporate a little delay to avoid "clobbering"
58                    // the clipboard, since it might still be in use by the
59                    // application that just updated it. Including the resources
60                    // that were apart of the update.
61                    //
62                    // Note that there are two distinct states we might clobber:
63                    // * The clipboard itself may only be open by one process at a
64                    //   time.
65                    // * Any resources sent over the clipboard may only be locked by
66                    //   one process at a time (GlobalLock / GlobalUnlock).
67                    //
68                    // If these overlap in the sending process, it might result in
69                    // it ironically enough failing to send the clipboard data.
70                    //
71                    // So as a best effort, we impose a minor timeout of
72                    // INITIAL_MILLIS to hopefully avoid this.
73                    let Ok(result) = self.poll_clipboard(msg.hwnd) else {
74                        winuser::SetTimer(msg.hwnd, CLIPBOARD_RETRY_TIMER, RETRY_MILLIS, None);
75                        self.attempts = 1;
76                        return true;
77                    };
78
79                    if let Some(clipboard_event) = result {
80                        _ = self.events_tx.send(WindowEvent::Clipboard(clipboard_event));
81                    }
82
83                    true
84                }
85                _ => false,
86            },
87            _ => false,
88        }
89    }
90
91    fn populate_formats(&mut self) {
92        self.supported = 'out: {
93            for format in Clipboard::updated_formats::<16>() {
94                if matches!(
95                    format,
96                    ClipboardFormat::DIBV5 | ClipboardFormat::TEXT | ClipboardFormat::UNICODETEXT
97                ) {
98                    break 'out Some(format);
99                }
100            }
101
102            None
103        };
104    }
105
106    unsafe fn handle_timer(&mut self, hwnd: HWND) {
107        let result = match self.poll_clipboard(hwnd) {
108            Ok(result) => result,
109            Err(error) => {
110                if self.attempts >= RETRY_MAX_ATTEMPTS {
111                    winuser::KillTimer(hwnd, CLIPBOARD_RETRY_TIMER);
112                    self.attempts = 0;
113                    _ = self.events_tx.send(WindowEvent::Error(Error::new(
114                        ErrorKind::ClipboardPoll(error),
115                    )));
116                } else {
117                    if self.attempts == 0 {
118                        winuser::SetTimer(hwnd, CLIPBOARD_RETRY_TIMER, RETRY_MILLIS, None);
119                    }
120
121                    self.attempts += 1;
122                }
123
124                return;
125            }
126        };
127
128        winuser::KillTimer(hwnd, CLIPBOARD_RETRY_TIMER);
129        self.attempts = 0;
130
131        if let Some(clipboard_event) = result {
132            _ = self.events_tx.send(WindowEvent::Clipboard(clipboard_event));
133        }
134    }
135
136    pub(super) unsafe fn poll_clipboard(
137        &mut self,
138        hwnd: HWND,
139    ) -> Result<Option<ClipboardEvent>, WindowError> {
140        let clipboard = Clipboard::new(hwnd).map_err(WindowError::OpenClipboard)?;
141
142        let Some(format) = self.supported else {
143            return Ok(None);
144        };
145
146        let data = clipboard
147            .data(format)
148            .map_err(WindowError::GetClipboardData)?;
149        let data = data.lock().map_err(WindowError::LockClipboardData)?;
150
151        // We've successfully locked the data, so take it from here.
152        self.supported = None;
153
154        let clipboard_event = match format {
155            ClipboardFormat::DIBV5 => ClipboardEvent::BitMap(data.as_slice().to_vec()),
156            ClipboardFormat::TEXT => {
157                let data = data.as_slice();
158
159                let data = match data {
160                    [head @ .., 0] => head,
161                    rest => rest,
162                };
163
164                let Ok(string) = str::from_utf8(data) else {
165                    return Ok(None);
166                };
167
168                ClipboardEvent::Text(string.to_owned())
169            }
170            ClipboardFormat::UNICODETEXT => {
171                let data = data.as_wide_slice();
172
173                let data = match data {
174                    [head @ .., 0] => head,
175                    rest => rest,
176                };
177
178                let Ok(string) = String::from_utf16(data) else {
179                    return Ok(None);
180                };
181
182                ClipboardEvent::Text(string.to_owned())
183            }
184            _ => {
185                return Ok(None);
186            }
187        };
188
189        Ok(Some(clipboard_event))
190    }
191}