11// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeclip/
22
33use std:: any:: Any ;
4+ use std:: sync:: atomic:: { AtomicBool , Ordering } ;
45use std:: sync:: { Arc , Mutex , PoisonError } ;
56
67use ironrdp:: cliprdr:: backend:: { ClipboardMessage , CliprdrBackend } ;
@@ -69,13 +70,29 @@ impl ClipboardText {
6970#[ derive( Clone , Debug ) ]
7071pub ( crate ) struct Clipboard < S > {
7172 store : ClipboardText ,
73+ ready : Arc < AtomicBool > ,
7274 sink : S ,
7375}
7476
7577impl < S : ClipboardSink + Clone > Clipboard < S > {
78+ /// A clipboard that may advertise from the moment it exists — the cliprdr *server* role,
79+ /// which opens the channel itself and is free to announce formats at any time.
7680 pub ( crate ) fn new ( sink : S ) -> Self {
81+ Self :: with_readiness ( sink, true )
82+ }
83+
84+ /// A clipboard that holds offers back until [`CliprdrBackend::on_ready`] — the cliprdr
85+ /// *client* role, whose format list is only legal once the remote's initialization
86+ /// sequence has run ([MS-RDPECLIP] 3.1.5.1). Text offered before then seeds the store and
87+ /// rides the initialization batch instead of racing ahead of it.
88+ pub ( crate ) fn deferred ( sink : S ) -> Self {
89+ Self :: with_readiness ( sink, false )
90+ }
91+
92+ fn with_readiness ( sink : S , ready : bool ) -> Self {
7793 Self {
7894 store : ClipboardText :: default ( ) ,
95+ ready : Arc :: new ( AtomicBool :: new ( ready) ) ,
7996 sink,
8097 }
8198 }
@@ -87,6 +104,7 @@ impl<S: ClipboardSink + Clone> Clipboard<S> {
87104 pub ( crate ) fn backend ( & self ) -> TextClipboard < S > {
88105 TextClipboard {
89106 store : self . store . clone ( ) ,
107+ ready : Arc :: clone ( & self . ready ) ,
90108 sink : self . sink . clone ( ) ,
91109 paste_format : None ,
92110 }
@@ -96,14 +114,17 @@ impl<S: ClipboardSink + Clone> Clipboard<S> {
96114 pub ( crate ) fn offer ( & self , mut text : String ) {
97115 truncate_for_wire ( & mut text, MAX_CLIPBOARD_BYTES ) ;
98116 self . store . set ( text) ;
99- advertise_text ( & self . sink ) ;
117+ if self . ready . load ( Ordering :: SeqCst ) {
118+ advertise_text ( & self . sink ) ;
119+ }
100120 }
101121}
102122
103123/// The "many" per-channel side of the clipboard bridge
104124#[ derive( Debug ) ]
105125pub ( crate ) struct TextClipboard < S > {
106126 store : ClipboardText ,
127+ ready : Arc < AtomicBool > ,
107128 sink : S ,
108129 paste_format : Option < ClipboardFormatId > ,
109130}
@@ -127,11 +148,26 @@ impl<S: ClipboardSink> CliprdrBackend for TextClipboard<S> {
127148 ClipboardGeneralCapabilityFlags :: empty ( )
128149 }
129150
130- fn on_ready ( & mut self ) { }
151+ fn on_ready ( & mut self ) {
152+ // Text offered while the channel was coming up was stored but not announced; announce
153+ // it now. If it was already carried by the initialization batch this repeats one format
154+ // list of identical content, which is legal and cheap — unlike an offer that outruns
155+ // the initialization sequence, which wedges the remote's clipboard for the session.
156+ if !self . ready . swap ( true , Ordering :: SeqCst ) && !self . store . get ( ) . is_empty ( ) {
157+ advertise_text ( & self . sink ) ;
158+ }
159+ }
131160
132- // Remote asks to advertise available contents
161+ /// Remote asks to advertise available contents. Answered unconditionally: this is the
162+ /// remote's Monitor Ready, and the format list replying to it is what carries our
163+ /// capabilities and completes the initialization sequence. Staying silent because there is
164+ /// nothing to offer leaves the channel unusable in both directions, so an empty clipboard
165+ /// answers with an empty format list — the standard "nothing to offer" announcement.
133166 fn on_request_format_list ( & mut self ) {
134- if !self . store . get ( ) . is_empty ( ) {
167+ if self . store . get ( ) . is_empty ( ) {
168+ self . sink
169+ . request ( ClipboardMessage :: SendInitiateCopy ( vec ! [ ] ) ) ;
170+ } else {
135171 advertise_text ( & self . sink ) ;
136172 }
137173 }
@@ -211,19 +247,38 @@ mod tests {
211247 }
212248 }
213249
214- fn fixture ( ) -> (
250+ type Fixture = (
215251 Clipboard < TestSink > ,
216252 TextClipboard < TestSink > ,
217253 std:: sync:: mpsc:: Receiver < ClipboardMessage > ,
218254 std:: sync:: mpsc:: Receiver < String > ,
219- ) {
255+ ) ;
256+
257+ fn fixture ( ) -> Fixture {
258+ build_fixture ( Clipboard :: new)
259+ }
260+
261+ fn deferred_fixture ( ) -> Fixture {
262+ build_fixture ( Clipboard :: deferred)
263+ }
264+
265+ fn build_fixture ( make : fn ( TestSink ) -> Clipboard < TestSink > ) -> Fixture {
220266 let ( requests, requests_rx) = channel ( ) ;
221267 let ( texts, texts_rx) = channel ( ) ;
222- let clipboard = Clipboard :: new ( TestSink { requests, texts } ) ;
268+ let clipboard = make ( TestSink { requests, texts } ) ;
223269 let backend = clipboard. backend ( ) ;
224270 ( clipboard, backend, requests_rx, texts_rx)
225271 }
226272
273+ fn offered_formats ( message : ClipboardMessage ) -> Vec < ClipboardFormatId > {
274+ match message {
275+ ClipboardMessage :: SendInitiateCopy ( formats) => {
276+ formats. iter ( ) . map ( ClipboardFormat :: id) . collect ( )
277+ }
278+ _ => panic ! ( "expected a format list" ) ,
279+ }
280+ }
281+
227282 /// A remote copy has to be pulled: the format list only names what is available.
228283 #[ test]
229284 fn remote_text_copy_triggers_a_paste_request ( ) {
@@ -308,21 +363,79 @@ mod tests {
308363 assert ! ( stored. ends_with( '🦀' ) ) ;
309364 }
310365
311- /// A rebuilt channel asks for a fresh format list; text copied through the previous
312- /// channel generation must be re-advertised, an empty store must not be.
366+ /// A channel asking for a format list is running its initialization sequence, and the
367+ /// reply is what completes it — so an empty store answers with an empty list rather than
368+ /// staying silent. Text copied through a previous channel generation is re-advertised.
313369 #[ test]
314- fn channel_bringup_readvertises_stored_text ( ) {
370+ fn channel_bringup_always_answers_with_a_format_list ( ) {
315371 let ( clipboard, mut backend, requests, _texts) = fixture ( ) ;
316372 backend. on_request_format_list ( ) ;
317- assert ! ( requests. try_recv( ) . is_err ( ) ) ;
373+ assert_eq ! ( offered_formats ( requests. try_recv( ) . unwrap ( ) ) , [ ] ) ;
318374
319375 clipboard. offer ( "kept" . to_owned ( ) ) ;
320376 let _ = requests. try_recv ( ) ;
321377 let mut next = clipboard. backend ( ) ;
322378 next. on_request_format_list ( ) ;
379+ assert_eq ! (
380+ offered_formats( requests. try_recv( ) . unwrap( ) ) ,
381+ [ ClipboardFormatId :: CF_UNICODETEXT ]
382+ ) ;
383+ }
384+
385+ /// A deferred clipboard belongs to a channel that may not announce formats before the
386+ /// remote's initialization sequence has run: text offered early is stored silently and
387+ /// announced once the channel reports itself ready.
388+ #[ test]
389+ fn a_deferred_clipboard_holds_offers_until_ready ( ) {
390+ let ( clipboard, mut backend, requests, _texts) = deferred_fixture ( ) ;
391+
392+ clipboard. offer ( "early" . to_owned ( ) ) ;
393+ assert ! ( requests. try_recv( ) . is_err( ) ) ;
394+
395+ backend. on_ready ( ) ;
396+ assert_eq ! (
397+ offered_formats( requests. try_recv( ) . unwrap( ) ) ,
398+ [ ClipboardFormatId :: CF_UNICODETEXT ]
399+ ) ;
400+ backend. on_format_data_request ( FormatDataRequest {
401+ format : ClipboardFormatId :: CF_UNICODETEXT ,
402+ } ) ;
323403 assert ! ( matches!(
324404 requests. try_recv( ) ,
325- Ok ( ClipboardMessage :: SendInitiateCopy ( _) )
405+ Ok ( ClipboardMessage :: SendFormatData ( _) )
326406 ) ) ;
407+
408+ clipboard. offer ( "later" . to_owned ( ) ) ;
409+ assert_eq ! (
410+ offered_formats( requests. try_recv( ) . unwrap( ) ) ,
411+ [ ClipboardFormatId :: CF_UNICODETEXT ]
412+ ) ;
413+ }
414+
415+ /// Nothing was offered while the channel came up, so becoming ready has nothing to
416+ /// announce — the initialization reply already said as much.
417+ #[ test]
418+ fn an_idle_deferred_clipboard_announces_nothing_when_ready ( ) {
419+ let ( _clipboard, mut backend, requests, _texts) = deferred_fixture ( ) ;
420+
421+ backend. on_request_format_list ( ) ;
422+ assert_eq ! ( offered_formats( requests. try_recv( ) . unwrap( ) ) , [ ] ) ;
423+
424+ backend. on_ready ( ) ;
425+ assert ! ( requests. try_recv( ) . is_err( ) ) ;
426+ }
427+
428+ /// Text offered before the remote asked for a format list rides that reply, which is the
429+ /// earliest legal moment to announce it.
430+ #[ test]
431+ fn early_text_rides_the_initialization_format_list ( ) {
432+ let ( clipboard, mut backend, requests, _texts) = deferred_fixture ( ) ;
433+
434+ clipboard. offer ( "early" . to_owned ( ) ) ;
435+ backend. on_request_format_list ( ) ;
436+ assert_eq ! (
437+ offered_formats( requests. try_recv( ) . unwrap( ) ) ,
438+ [ ClipboardFormatId :: CF_UNICODETEXT ]
439+ ) ;
327440 }
328441}
0 commit comments