Skip to content

Commit 7d25fcb

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/vault-ssh-certificate-auth
2 parents 3325418 + c80cf33 commit 7d25fcb

9 files changed

Lines changed: 249 additions & 71 deletions

File tree

.github/workflows/docker.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8
4949

5050
- name: Set up Docker Buildx
51-
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c
51+
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e
5252

5353
- name: Log into registry ${{ env.REGISTRY }}
5454
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
@@ -129,7 +129,7 @@ jobs:
129129
merge-multiple: true
130130

131131
- name: Set up Docker Buildx
132-
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c
132+
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e
133133

134134
- name: Log into registry ${{ env.REGISTRY }}
135135
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f

Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,6 @@ debug = "line-tables-only"
190190

191191
[profile.release]
192192
lto = true
193-
panic = "abort"
194193
strip = "debuginfo"
195194

196195
[profile.release-no-lto]

warpgate-protocol-rdp/src/client/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ pub async fn run(
9999
);
100100

101101
let (clipboard_tx, clipboard_rx) = unbounded_channel();
102-
let clipboard = Clipboard::new(ClientClipboardSink(clipboard_tx));
102+
let clipboard = Clipboard::deferred(ClientClipboardSink(clipboard_tx));
103103

104104
let (connection_result, framed) = tokio::time::timeout(
105105
HANDSHAKE_TIMEOUT,

warpgate-protocol-rdp/src/clipboard.rs

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeclip/
22

33
use std::any::Any;
4+
use std::sync::atomic::{AtomicBool, Ordering};
45
use std::sync::{Arc, Mutex, PoisonError};
56

67
use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackend};
@@ -69,13 +70,29 @@ impl ClipboardText {
6970
#[derive(Clone, Debug)]
7071
pub(crate) struct Clipboard<S> {
7172
store: ClipboardText,
73+
ready: Arc<AtomicBool>,
7274
sink: S,
7375
}
7476

7577
impl<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)]
105125
pub(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
}

warpgate-protocol-rdp/src/server/bridge.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,5 +119,6 @@ pub(super) async fn connect_backend(
119119
abort_tx,
120120
frame_bridge,
121121
recorder,
122+
dialed_at: Some(screen),
122123
})
123124
}

warpgate-protocol-rdp/src/server/mod.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ struct BackendBridge {
7171
/// Shared with `frame_bridge`; used to record viewer input alongside the
7272
/// framebuffer. `None` when recording is disabled.
7373
recorder: Option<Arc<DesktopRecorder>>,
74+
/// The size the target was dialed at, until the viewer's own negotiated size has been
75+
/// reconciled against it; `None` once that has happened. A viewer whose buffered input
76+
/// dials the target before the capability exchange reports its resolution is dialed at
77+
/// the advertised default, and this is what lets the resolution that follows still reach
78+
/// the target. Only the viewer's *first* size is its own — every later one echoes a
79+
/// resize the target itself drove, and re-sending that would fight the target.
80+
dialed_at: Option<warpgate_desktop_ui::Screen>,
7481
}
7582

7683
impl BackendBridge {
@@ -378,10 +385,28 @@ async fn control_loop(
378385
screen,
379386
)
380387
.await?;
388+
// A target dialed before this arrived is running at the advertised default,
389+
// so bring it to the resolution the viewer actually negotiated. `take` spends
390+
// the reconciliation whether or not it resizes anything, so the `Size` a
391+
// target-driven resize echoes back here is never bounced at the target.
392+
if let Some(backend) = &mut backend
393+
&& let Some(dialed_at) = backend.dialed_at.take()
394+
&& dialed_at != screen
395+
&& backend
396+
.input_tx
397+
.send(DesktopInput::Resize { width, height })
398+
.await
399+
.is_err()
400+
{
401+
break;
402+
}
381403
continue;
382404
}
383405
ServerEvent::ResizeRequest { width, height } => {
384-
if let Some(backend) = &backend {
406+
if let Some(backend) = &mut backend {
407+
// The viewer is driving the size itself now; reconciling the dial size
408+
// afterwards could only replay a stale one.
409+
backend.dialed_at = None;
385410
if backend
386411
.input_tx
387412
.send(DesktopInput::Resize { width, height })
@@ -404,8 +429,11 @@ async fn control_loop(
404429
ServerEvent::Input(input) => input,
405430
};
406431

407-
// A viewer that never negotiates a size won't emit `Size`; dial the pending target
408-
// on its first input so the session still connects (at the advertised default).
432+
// A viewer that never negotiates a size won't emit `Size`; dial the pending target on
433+
// its first input so the session still connects (at the advertised default). Input the
434+
// viewer sent during the handshake is delivered ahead of the negotiated size, so this
435+
// also fires for viewers that do negotiate one — the `Size` arm resizes the target once
436+
// that size arrives.
409437
dial_if_pending(
410438
&mut backend,
411439
&mut pending_dial,

0 commit comments

Comments
 (0)