From 2b501496d92330c5e3b80fa3311d30bfa2f6e195 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Wed, 8 Nov 2023 13:57:21 +0200 Subject: [PATCH] feat: clipboard support for web client (#259) --- Cargo.lock | 12 + Cargo.toml | 1 + crates/ironrdp-cliprdr-format/Cargo.toml | 23 + crates/ironrdp-cliprdr-format/README.md | 7 + crates/ironrdp-cliprdr-format/src/bitmap.rs | 754 ++++++++++++++++++ crates/ironrdp-cliprdr-format/src/html.rs | 149 ++++ crates/ironrdp-cliprdr-format/src/lib.rs | 4 + crates/ironrdp-cliprdr/src/backend.rs | 12 + crates/ironrdp-cliprdr/src/lib.rs | 2 + crates/ironrdp-cliprdr/src/pdu/format_list.rs | 9 +- crates/ironrdp-fuzzing/Cargo.toml | 3 +- crates/ironrdp-fuzzing/src/oracles/mod.rs | 14 + crates/ironrdp-pdu/src/cursor.rs | 6 + crates/ironrdp-pdu/src/lib.rs | 1 + crates/ironrdp-rdpdr/src/backend/mod.rs | 2 +- crates/ironrdp-rdpdr/src/lib.rs | 3 +- crates/ironrdp-testsuite-core/Cargo.toml | 1 + .../test_data/pdu/clipboard/cf_dib.pdu | Bin 0 -> 340 bytes .../test_data/pdu/clipboard/cf_dibv5.pdu | Bin 0 -> 424 bytes .../test_data/pdu/clipboard/cf_html.pdu | Bin 0 -> 892 bytes .../tests/clipboard/format.rs | 55 ++ .../tests/{clipboard.rs => clipboard/mod.rs} | 14 +- crates/ironrdp-web/Cargo.toml | 3 +- crates/ironrdp-web/src/clipboard/mod.rs | 566 +++++++++++++ .../ironrdp-web/src/clipboard/transaction.rs | 110 +++ crates/ironrdp-web/src/lib.rs | 1 + crates/ironrdp-web/src/session.rs | 146 +++- fuzz/fuzz_targets/cliprdr_format.rs | 7 + .../src/iron-remote-gui.svelte | 399 ++++++++- .../src/services/wasm-bridge.service.ts | 48 +- 30 files changed, 2319 insertions(+), 33 deletions(-) create mode 100644 crates/ironrdp-cliprdr-format/Cargo.toml create mode 100644 crates/ironrdp-cliprdr-format/README.md create mode 100644 crates/ironrdp-cliprdr-format/src/bitmap.rs create mode 100644 crates/ironrdp-cliprdr-format/src/html.rs create mode 100644 crates/ironrdp-cliprdr-format/src/lib.rs create mode 100644 crates/ironrdp-testsuite-core/test_data/pdu/clipboard/cf_dib.pdu create mode 100644 crates/ironrdp-testsuite-core/test_data/pdu/clipboard/cf_dibv5.pdu create mode 100644 crates/ironrdp-testsuite-core/test_data/pdu/clipboard/cf_html.pdu create mode 100644 crates/ironrdp-testsuite-core/tests/clipboard/format.rs rename crates/ironrdp-testsuite-core/tests/{clipboard.rs => clipboard/mod.rs} (96%) create mode 100644 crates/ironrdp-web/src/clipboard/mod.rs create mode 100644 crates/ironrdp-web/src/clipboard/transaction.rs create mode 100644 fuzz/fuzz_targets/cliprdr_format.rs diff --git a/Cargo.lock b/Cargo.lock index cdb869e8..812da4b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1763,6 +1763,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "ironrdp-cliprdr-format" +version = "0.1.0" +dependencies = [ + "ironrdp-pdu", + "png", + "thiserror", +] + [[package]] name = "ironrdp-cliprdr-native" version = "0.1.0" @@ -1816,6 +1825,7 @@ version = "0.0.0" dependencies = [ "arbitrary", "ironrdp-cliprdr", + "ironrdp-cliprdr-format", "ironrdp-graphics", "ironrdp-pdu", "ironrdp-rdpdr", @@ -1952,6 +1962,7 @@ dependencies = [ "expect-test", "hex", "ironrdp-cliprdr", + "ironrdp-cliprdr-format", "ironrdp-connector", "ironrdp-fuzzing", "ironrdp-graphics", @@ -1999,6 +2010,7 @@ dependencies = [ "gloo-net", "gloo-timers", "ironrdp", + "ironrdp-cliprdr-format", "ironrdp-futures", "ironrdp-rdcleanpath", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 78f7aa05..6ffb7432 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ ironrdp-async = { version = "0.1", path = "crates/ironrdp-async" } ironrdp-blocking = { version = "0.1", path = "crates/ironrdp-blocking" } ironrdp-cliprdr = { version = "0.1", path = "crates/ironrdp-cliprdr" } ironrdp-cliprdr-native = { version = "0.1", path = "crates/ironrdp-cliprdr-native" } +ironrdp-cliprdr-format = { version = "0.1", path = "crates/ironrdp-cliprdr-format" } ironrdp-connector = { version = "0.1", path = "crates/ironrdp-connector" } ironrdp-dvc = { version = "0.1", path = "crates/ironrdp-dvc" } ironrdp-error = { version = "0.1", path = "crates/ironrdp-error" } diff --git a/crates/ironrdp-cliprdr-format/Cargo.toml b/crates/ironrdp-cliprdr-format/Cargo.toml new file mode 100644 index 00000000..a03eff21 --- /dev/null +++ b/crates/ironrdp-cliprdr-format/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ironrdp-cliprdr-format" +version = "0.1.0" +readme = "README.md" +description = "CLIPRDR format conversion library" +edition.workspace = true +license.workspace = true + +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[lib] +doctest = false +test = false + +[dependencies] +ironrdp-pdu.workspace = true +thiserror.workspace = true + +png = "0.17" \ No newline at end of file diff --git a/crates/ironrdp-cliprdr-format/README.md b/crates/ironrdp-cliprdr-format/README.md new file mode 100644 index 00000000..9666d87f --- /dev/null +++ b/crates/ironrdp-cliprdr-format/README.md @@ -0,0 +1,7 @@ +# IronRDP CLIPRDR formats decoding/encoding library + +This Library provides the conversion logic between RDP-specific clipboard formats and +widely used formats like PNG for images, plain string for HTML etc. + +### INVARIANTS +- This crate expects the target machine's pointer size (usize) to be equal or greater than 32bits \ No newline at end of file diff --git a/crates/ironrdp-cliprdr-format/src/bitmap.rs b/crates/ironrdp-cliprdr-format/src/bitmap.rs new file mode 100644 index 00000000..ecc01f76 --- /dev/null +++ b/crates/ironrdp-cliprdr-format/src/bitmap.rs @@ -0,0 +1,754 @@ +use ironrdp_pdu::cursor::{ReadCursor, WriteCursor}; +use ironrdp_pdu::{cast_int, ensure_fixed_part_size, invalid_message_err, PduDecode, PduEncode, PduResult}; +use thiserror::Error; + +/// Maximum size of PNG image that could be placed on the clipboard. +const MAX_BUFFER_SIZE: usize = 64 * 1024 * 1024; // 64 MB + +#[derive(Debug, Error)] +pub enum BitmapError { + #[error("Invalid bitmap header")] + InvalidHeader(ironrdp_pdu::PduError), + #[error("Unsupported bitmap: {0}")] + Unsupported(&'static str), + #[error("One of bitmap's dimensions is invalid")] + InvalidSize, + #[error("Buffer size required for allocation is too big")] + BufferTooBig, + #[error("Image width is too big")] + WidthTooBig, + #[error("Image height is too big")] + HeightTooBig, + #[error("PNG encoding error")] + PngEncode(#[from] png::EncodingError), + #[error("PNG decoding error")] + PngDecode(#[from] png::DecodingError), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct BitmapCompression(u32); + +#[allow(dead_code)] +impl BitmapCompression { + const RGB: Self = Self(0x0000); + const RLE8: Self = Self(0x0001); + const RLE4: Self = Self(0x0002); + const BITFIELDS: Self = Self(0x0003); + const JPEG: Self = Self(0x0004); + const PNG: Self = Self(0x0005); + const CMYK: Self = Self(0x000B); + const CMYKRLE8: Self = Self(0x000C); + const CMYKRLE4: Self = Self(0x000D); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct ColorSpace(u32); + +#[allow(dead_code)] +impl ColorSpace { + const CALIBRATED_RGB: Self = Self(0x00000000); + const SRGB: Self = Self(0x73524742); + const WINDOWS: Self = Self(0x57696E20); + const PROFILE_LINKED: Self = Self(0x4C494E4B); + const PROFILE_EMBEDDED: Self = Self(0x4D424544); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct BitmapIntent(u32); + +#[allow(dead_code)] +impl BitmapIntent { + const LCS_GM_ABS_COLORIMETRIC: Self = Self(0x00000008); + const LCS_GM_BUSINESS: Self = Self(0x00000001); + const LCS_GM_GRAPHICS: Self = Self(0x00000002); + const LCS_GM_IMAGES: Self = Self(0x00000004); +} + +type Fxpt2Dot30 = u32; // (LONG) + +#[derive(Default)] +struct Ciexyz { + x: Fxpt2Dot30, + y: Fxpt2Dot30, + z: Fxpt2Dot30, +} + +#[derive(Default)] +struct CiexyzTriple { + red: Ciexyz, + green: Ciexyz, + blue: Ciexyz, +} + +impl CiexyzTriple { + const NAME: &str = "CIEXYZTRIPLE"; + const FIXED_PART_SIZE: usize = 4 * 3 * 3; // 4(LONG) * 3(xyz) * 3(red, green, blue) +} + +impl<'a> PduDecode<'a> for CiexyzTriple { + fn decode(src: &mut ReadCursor<'a>) -> PduResult { + ensure_fixed_part_size!(in: src); + + let red = Ciexyz { + x: src.read_u32(), + y: src.read_u32(), + z: src.read_u32(), + }; + + let green = Ciexyz { + x: src.read_u32(), + y: src.read_u32(), + z: src.read_u32(), + }; + + let blue = Ciexyz { + x: src.read_u32(), + y: src.read_u32(), + z: src.read_u32(), + }; + + Ok(Self { red, green, blue }) + } +} + +impl PduEncode for CiexyzTriple { + fn encode(&self, dst: &mut WriteCursor<'_>) -> PduResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u32(self.red.x); + dst.write_u32(self.red.y); + dst.write_u32(self.red.z); + + dst.write_u32(self.green.x); + dst.write_u32(self.green.y); + dst.write_u32(self.green.z); + + dst.write_u32(self.blue.x); + dst.write_u32(self.blue.y); + dst.write_u32(self.blue.z); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// Header used in `CF_DIB` formats, part of [BITMAPINFO] +/// +/// We don't use the optional `bmiColors` field, because it is only relevant for bitmaps with +/// bpp < 24, which are not supported yet, therefore only fixed part of the header is implemented. +/// +/// INVARIANT: `width` and `height` magnitudes are less than or equal to `u16::MAX`. +/// INVARIANT: `bit_count` is less than or equal to `32`. +/// +/// [BITMAPINFO]: https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapinfo +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BitmapInfoHeader { + width: i32, + height: i32, + bit_count: u16, + compression: BitmapCompression, + size_image: u32, + x_pels_per_meter: i32, + y_pels_per_meter: i32, + clr_used: u32, + clr_important: u32, +} + +impl BitmapInfoHeader { + const FIXED_PART_SIZE: usize = 4 // biSize (DWORD) + + 4 // biWidth (LONG) + + 4 // biHeight (LONG) + + 2 // biPlanes (WORD) + + 2 // biBitCount (WORD) + + 4 // biCompression (DWORD) + + 4 // biSizeImage (DWORD) + + 4 // biXPelsPerMeter (LONG) + + 4 // biYPelsPerMeter (LONG) + + 4 // biClrUsed (DWORD) + + 4; // biClrImportant (DWORD) + + const NAME: &str = "BITMAPINFOHEADER"; + + fn validate_invariants(&self) -> PduResult<()> { + check_invariant(self.width.abs() <= u16::MAX.into()) + .ok_or_else(|| invalid_message_err!("biWidth", "width is too big"))?; + check_invariant(self.height.abs() <= u16::MAX.into()) + .ok_or_else(|| invalid_message_err!("biHeight", "height is too big"))?; + check_invariant(self.bit_count <= 32).ok_or_else(|| invalid_message_err!("biBitCount", "invalid bit count"))?; + + Ok(()) + } + + fn encode_with_size(&self, dst: &mut WriteCursor<'_>, size: u32) -> PduResult<()> { + ensure_fixed_part_size!(in: dst); + + self.validate_invariants()?; + + dst.write_u32(size); + dst.write_i32(self.width); + dst.write_i32(self.height); + dst.write_u16(1); // biPlanes + dst.write_u16(self.bit_count); + dst.write_u32(self.compression.0); + dst.write_u32(self.size_image); + dst.write_i32(self.x_pels_per_meter); + dst.write_i32(self.y_pels_per_meter); + dst.write_u32(self.clr_used); + dst.write_u32(self.clr_important); + + Ok(()) + } + + fn decode_with_size(src: &mut ReadCursor<'_>) -> PduResult<(Self, u32)> { + ensure_fixed_part_size!(in: src); + + let size = src.read_u32(); + + let width = src.read_i32(); + let height = src.read_i32(); + let planes = src.read_u16(); + if planes != 1 { + return Err(invalid_message_err!("biPlanes", "invalid planes count")); + } + let bit_count = src.read_u16(); + let compression = BitmapCompression(src.read_u32()); + let size_image = src.read_u32(); + let x_pels_per_meter = src.read_i32(); + let y_pels_per_meter = src.read_i32(); + let clr_used = src.read_u32(); + let clr_important = src.read_u32(); + + let header = Self { + width, + height, + bit_count, + compression, + size_image, + x_pels_per_meter, + y_pels_per_meter, + clr_used, + clr_important, + }; + + header.validate_invariants()?; + + Ok((header, size)) + } + + fn width(&self) -> u16 { + // Cast is safe, invariant is checked in `encode_with_size` and `decode_with_size`. + u16::try_from(self.width.abs()).unwrap() + } + + fn height(&self) -> u16 { + // Cast is safe, invariant is checked in `encode_with_size` and `decode_with_size`. + u16::try_from(self.height.abs()).unwrap() + } + + fn flip_vertically(&self) -> bool { + self.height >= 0 + } +} + +impl PduEncode for BitmapInfoHeader { + fn encode(&self, dst: &mut WriteCursor<'_>) -> PduResult<()> { + let size = cast_int!("biSize", Self::FIXED_PART_SIZE)?; + self.encode_with_size(dst, size) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> PduDecode<'a> for BitmapInfoHeader { + fn decode(src: &mut ReadCursor<'a>) -> PduResult { + let (header, size) = Self::decode_with_size(src)?; + let size: usize = cast_int!("biSize", size)?; + + if size != Self::FIXED_PART_SIZE { + return Err(invalid_message_err!("biSize", "invalid V1 bitmap info header size")); + } + + Ok(header) + } +} + +/// Header used in `CF_DIBV5` formats, defined as [BITMAPV5HEADER] +/// +/// [BITMAPV5HEADER]: https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header +struct BitmapV5Header { + header_v1: BitmapInfoHeader, + red_mask: u32, + green_mask: u32, + blue_mask: u32, + alpha_mask: u32, + color_space: ColorSpace, + endpoints: CiexyzTriple, + gamma_red: u32, + gamma_green: u32, + gamma_blue: u32, + intent: BitmapIntent, + profile_data: u32, + profile_size: u32, +} + +impl BitmapV5Header { + const FIXED_PART_SIZE: usize = BitmapInfoHeader::FIXED_PART_SIZE // BITMAPV5HEADER + + 4 // bV5RedMask (DWORD) + + 4 // bV5GreenMask (DWORD) + + 4 // bV5BlueMask (DWORD) + + 4 // bV5AlphaMask (DWORD) + + 4 // bV5CSType (DWORD) + + CiexyzTriple::FIXED_PART_SIZE // bV5Endpoints (CIEXYZTRIPLE) + + 4 // bV5GammaRed (DWORD) + + 4 // bV5GammaGreen (DWORD) + + 4 // bV5GammaBlue (DWORD) + + 4 // bV5Intent (DWORD) + + 4 // bV5ProfileData (DWORD) + + 4 // bV5ProfileSize (DWORD) + + 4; // bV5Reserved (DWORD) + + const NAME: &str = "BITMAPV5HEADER"; +} + +impl PduEncode for BitmapV5Header { + fn encode(&self, dst: &mut WriteCursor<'_>) -> PduResult<()> { + ensure_fixed_part_size!(in: dst); + + let size = cast_int!("biSize", Self::FIXED_PART_SIZE)?; + self.header_v1.encode_with_size(dst, size)?; + + dst.write_u32(self.red_mask); + dst.write_u32(self.green_mask); + dst.write_u32(self.blue_mask); + dst.write_u32(self.alpha_mask); + dst.write_u32(self.color_space.0); + self.endpoints.encode(dst)?; + dst.write_u32(self.gamma_red); + dst.write_u32(self.gamma_green); + dst.write_u32(self.gamma_blue); + dst.write_u32(self.intent.0); + dst.write_u32(self.profile_data); + dst.write_u32(self.profile_size); + dst.write_u32(0); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> PduDecode<'a> for BitmapV5Header { + fn decode(src: &mut ReadCursor<'a>) -> PduResult { + ensure_fixed_part_size!(in: src); + + let (header_v1, size) = BitmapInfoHeader::decode_with_size(src)?; + let size: usize = cast_int!("biSize", size)?; + + if size != Self::FIXED_PART_SIZE { + return Err(invalid_message_err!("biSize", "invalid V5 bitmap info header size")); + } + + let red_mask = src.read_u32(); + let green_mask = src.read_u32(); + let blue_mask = src.read_u32(); + let alpha_mask = src.read_u32(); + let color_space_type = ColorSpace(src.read_u32()); + let endpoints = CiexyzTriple::decode(src)?; + let gamma_red = src.read_u32(); + let gamma_green = src.read_u32(); + let gamma_blue = src.read_u32(); + let intent = BitmapIntent(src.read_u32()); + let profile_data = src.read_u32(); + let profile_size = src.read_u32(); + let _reserved = src.read_u32(); + + Ok(Self { + header_v1, + red_mask, + green_mask, + blue_mask, + alpha_mask, + color_space: color_space_type, + endpoints, + gamma_red, + gamma_green, + gamma_blue, + intent, + profile_data, + profile_size, + }) + } +} + +fn validate_v1_header(header: &BitmapInfoHeader) -> Result<(), BitmapError> { + if header.width < 0 { + return Err(BitmapError::Unsupported("negative width")); + } + + if header.width == 0 || header.height == 0 { + return Err(BitmapError::InvalidSize); + } + + // In the modern world bitmaps with bpp < 24 are rare, and it is even more rare for the bitmaps + // which are placed on the clipboard as DIBs, therefore we could safely skip the support for + // such bitmaps. + const SUPPORTED_BIT_COUNT: &[u16] = &[24, 32]; + + if !SUPPORTED_BIT_COUNT.contains(&header.bit_count) { + return Err(BitmapError::Unsupported("unsupported bit count")); + } + + // We support only uncompressed DIB bitmaps as it is the most common case for clipboard-copied + // bitmaps. + const SUPPORTED_COMPRESSION: &[BitmapCompression] = &[BitmapCompression::RGB]; + if !SUPPORTED_COMPRESSION.contains(&header.compression) { + return Err(BitmapError::Unsupported("unsupported compression")); + } + + // This is only relevant for bitmaps with bpp < 24, which are not supported. + if header.clr_used != 0 { + return Err(BitmapError::Unsupported("color table is not supported")); + } + + Ok(()) +} + +fn validate_v5_header(header: &BitmapV5Header) -> Result<(), BitmapError> { + validate_v1_header(&header.header_v1)?; + + const SUPPORTED_COLOR_SPACE: &[ColorSpace] = &[ + ColorSpace::SRGB, + // Assume that Windows color space is sRGB, either way we don't have enough information on + // the clipboard to convert it to other color spaces. + ColorSpace::WINDOWS, + ]; + + if !SUPPORTED_COLOR_SPACE.contains(&header.color_space) { + return Err(BitmapError::Unsupported("not supported color space")); + } + + Ok(()) +} + +struct PngEncoderInput { + frame_buffer: Vec, + width: usize, + height: usize, + color_type: png::ColorType, +} + +/// From MS docs: +/// For uncompressed RGB formats, the minimum stride is always the image width in bytes, rounded +/// up to the nearest DWORD (4 bytes). You can use the following formula to calculate the stride +/// and image size: +/// ``` +/// stride = ((((biWidth * biBitCount) + 31) & ~31) >> 3); +/// biSizeImage = abs(biHeight) * stride; +/// ``` +/// +/// INVARIANT: bit_count <= 32 +#[allow(clippy::arithmetic_side_effects)] +fn bmp_stride(width: u16, bit_count: u16) -> usize { + debug_assert!(bit_count <= 32); + (((usize::from(width) * usize::from(bit_count)) + 31) & !31) >> 3 +} + +fn transform_bitmap( + header: &BitmapInfoHeader, + input: &[u8], + preserve_alpha: bool, +) -> Result { + // If height is positive, DIB is bottom-up, but target PNG format is top-down. + let flip = header.flip_vertically(); + + let width = header.width(); + let height = header.height(); + + let bit_count = header.bit_count; + + let stride = bmp_stride(width, bit_count); + + let input_bytes_per_pixel = usize::from(bit_count / 8); + let color_type = if preserve_alpha { + png::ColorType::Rgba + } else { + png::ColorType::Rgb + }; + + let components = color_type.samples(); + debug_assert!(components <= 4); + + // INVARIANT: height * width * components <= u16::MAX * u16::MAX * 4 < usize::MAX + // This is always true because `components <= 4` is checked above, and width & height + // bounds are validated on PDU encode/decode + #[allow(clippy::arithmetic_side_effects)] + let frame_buffer_len = usize::from(height) * usize::from(width) * components; + + // Prevent allocation of huge frame buffers + check_invariant(frame_buffer_len <= MAX_BUFFER_SIZE).ok_or(BitmapError::BufferTooBig)?; + + let mut frame_buffer = vec![0u8; frame_buffer_len]; + + let mut strides_normal; + let mut strides_reversed; + + let strides: &mut dyn Iterator = if flip { + strides_reversed = input.chunks_exact(stride).rev(); + &mut strides_reversed + } else { + strides_normal = input.chunks_exact(stride); + &mut strides_normal + }; + + // DIB stores color as strided BGRA, PNG require packed RGBA. DIBv1 (CF_DIB) do not have alpha, + // and the fourth byte is always set to 0xFF. DIBv5 (CF_DIBV5) may have alpha, so we should + // preserve it if it is present. + let transform: fn((&mut [u8], &[u8])) = match (header.bit_count, color_type) { + (24 | 32, png::ColorType::Rgb) => |(pixel_out, pixel_in)| { + pixel_out[0] = pixel_in[2]; + pixel_out[1] = pixel_in[1]; + pixel_out[2] = pixel_in[0]; + }, + (24, png::ColorType::Rgba) => |(pixel_out, pixel_in)| { + pixel_out[0] = pixel_in[2]; + pixel_out[1] = pixel_in[1]; + pixel_out[2] = pixel_in[0]; + pixel_out[3] = 0xFF; + }, + (32, png::ColorType::Rgba) => |(pixel_out, pixel_in)| { + pixel_out[0] = pixel_in[2]; + pixel_out[1] = pixel_in[1]; + pixel_out[2] = pixel_in[0]; + pixel_out[3] = pixel_in[3]; + }, + _ => unreachable!("possible values are restricted by header validation and logic above"), + }; + + // INVARIANT: width * components <= u16::MAX * 4 < usize::MAX + // + // + #[allow(clippy::arithmetic_side_effects)] + let dst_chunk_size = usize::from(width) * components; + + frame_buffer + .chunks_exact_mut(dst_chunk_size) + .zip(strides) + .for_each(|(row, stride)| { + let input = stride.chunks_exact(input_bytes_per_pixel); + row.chunks_exact_mut(components).zip(input).for_each(transform); + }); + + Ok(PngEncoderInput { + frame_buffer, + width: width.into(), + height: height.into(), + color_type, + }) +} + +fn encode_png(input: PngEncoderInput) -> Result, BitmapError> { + let mut output: Vec = Vec::new(); + + let width: u32 = cast_int!("PNG encode", "width", input.width).unwrap(); + let height: u32 = cast_int!("PNG encode", "height", input.height).unwrap(); + + let mut encoder = png::Encoder::new(&mut output, width, height); + encoder.set_color(input.color_type); + encoder.set_depth(png::BitDepth::Eight); + + let mut writer = encoder.write_header()?; + writer.write_image_data(&input.frame_buffer)?; + writer.finish()?; + + Ok(output) +} + +/// Convert `CF_DIB` to PNG. +pub fn dib_to_png(input: &[u8]) -> Result, BitmapError> { + let mut src = ReadCursor::new(input); + let header = BitmapInfoHeader::decode(&mut src).map_err(BitmapError::InvalidHeader)?; + + validate_v1_header(&header)?; + + let png_inputs = transform_bitmap(&header, src.remaining(), false)?; + encode_png(png_inputs) +} + +/// Convert `CF_DIB` to PNG. +pub fn dibv5_to_png(input: &[u8]) -> Result, BitmapError> { + let mut src = ReadCursor::new(input); + let header = BitmapV5Header::decode(&mut src).map_err(BitmapError::InvalidHeader)?; + + validate_v5_header(&header)?; + + let png_inputs = transform_bitmap(&header.header_v1, src.remaining(), true)?; + encode_png(png_inputs) +} + +fn transform_png(info: png::OutputInfo, input_buffer: Vec) -> Result<(BitmapInfoHeader, Vec), BitmapError> { + let no_alpha = info.color_type != png::ColorType::Rgba; + + let stride = bmp_stride( + cast_int!("BMP stride", "biWidth", info.width).map_err(|_| BitmapError::InvalidSize)?, + 32, + ); + + let width_unsigned: u16 = u16::try_from(info.width).map_err(|_| BitmapError::WidthTooBig)?; + let height_unsigned: u16 = u16::try_from(info.height).map_err(|_| BitmapError::HeightTooBig)?; + + // INVARIANT: stride * height_unsigned <= usize::MAX. + // + // This never overflows, because stride can't be greater than `width_unsigned * 4`, + // and `width_unsigned * height_unsigned * 4` is guaranteed to be lesser or equal + // to `usize::MAX`. + #[allow(clippy::arithmetic_side_effects)] + let image_size: usize = stride * usize::from(height_unsigned); + + let header = BitmapInfoHeader { + width: width_unsigned.into(), + height: height_unsigned.into(), + bit_count: 32, + compression: BitmapCompression::RGB, + size_image: cast_int!("DIB header", "biImageSize", image_size).map_err(|_| BitmapError::InvalidSize)?, + x_pels_per_meter: 0, + y_pels_per_meter: 0, + clr_used: 0, + clr_important: 0, + }; + + // Row is in RGBA format + // INVARIANT: width_unsigned * 4 <= u16::MAX * 4 < usize::MAX + // This is always true because width_unsigned is validate above to be less or equal to u16::MAX + #[allow(clippy::arithmetic_side_effects)] + let row_size: usize = 4 * usize::from(width_unsigned); + + let mut output_buffer = vec![0; image_size]; + + let rows = input_buffer.chunks_exact(row_size); + + // Reverse strides to draw image bottom-up + let strides = output_buffer.chunks_exact_mut(stride).rev(); + + let transform: fn((&mut [u8], &[u8])) = if no_alpha { + |(pixel_out, pixel_in)| { + pixel_out[0] = pixel_in[2]; + pixel_out[1] = pixel_in[1]; + pixel_out[2] = pixel_in[0]; + pixel_out[3] = 0xFF; + } + } else { + |(pixel_out, pixel_in)| { + pixel_out[0] = pixel_in[2]; + pixel_out[1] = pixel_in[1]; + pixel_out[2] = pixel_in[0]; + pixel_out[3] = pixel_in[3]; + } + }; + + strides.zip(rows).for_each(|(output, input)| { + let input = input.chunks_exact(4); + output.chunks_exact_mut(4).zip(input).for_each(transform); + }); + + Ok((header, output_buffer)) +} + +fn decode_png(mut input: &[u8]) -> Result<(png::OutputInfo, Vec), BitmapError> { + let mut decoder = png::Decoder::new(&mut input); + + // We need to produce 32-bit DIB, so we should expand the palette to 32-bit RGBA. + decoder.set_transformations(png::Transformations::ALPHA | png::Transformations::EXPAND); + + let mut reader = decoder.read_info()?; + let mut buffer = vec![0; reader.output_buffer_size()]; + let info = reader.next_frame(&mut buffer)?; + buffer.truncate(info.buffer_size()); + + Ok((info, buffer)) +} + +/// Convert PNG to `CF_DIB` format. +pub fn png_to_cf_dib(input: &[u8]) -> Result, BitmapError> { + let (info, input_buffer) = decode_png(input)?; + let (header, output_buffer) = transform_png(info, input_buffer)?; + + let dib_buffer_size = header + .size() + .checked_add(output_buffer.len()) + .ok_or(BitmapError::BufferTooBig)?; + + check_invariant(dib_buffer_size <= MAX_BUFFER_SIZE).ok_or(BitmapError::BufferTooBig)?; + + let mut dib_buffer = vec![0; dib_buffer_size]; + { + let mut dst = WriteCursor::new(&mut dib_buffer); + header.encode(&mut dst).map_err(BitmapError::InvalidHeader)?; + dst.write_slice(&output_buffer); + } + + Ok(dib_buffer) +} + +/// Convert PNG to `CF_DIBV5` format. +pub fn png_to_cf_dibv5(input: &[u8]) -> Result, BitmapError> { + let (info, input_buffer) = decode_png(input)?; + let (header_v1, output_buffer) = transform_png(info, input_buffer)?; + + let header = BitmapV5Header { + header_v1, + // Windows sets these masks for 32-bit bitmaps even if BITFIELDS compression is not used. + red_mask: 0x00FF0000, + green_mask: 0x0000FF00, + blue_mask: 0x000000FF, + alpha_mask: 0xFF000000, + color_space: ColorSpace::SRGB, + endpoints: Default::default(), + gamma_red: 0, + gamma_green: 0, + gamma_blue: 0, + intent: BitmapIntent::LCS_GM_IMAGES, + profile_data: 0, + profile_size: 0, + }; + + let dib_buffer_size = header + .size() + .checked_add(output_buffer.len()) + .ok_or(BitmapError::BufferTooBig)?; + + check_invariant(dib_buffer_size <= MAX_BUFFER_SIZE).ok_or(BitmapError::BufferTooBig)?; + + let mut dib_buffer: Vec = vec![0; dib_buffer_size]; + { + let mut dst = WriteCursor::new(&mut dib_buffer); + header.encode(&mut dst).map_err(BitmapError::InvalidHeader)?; + dst.write_slice(&output_buffer); + } + + Ok(dib_buffer) +} + +#[inline] +#[must_use] +fn check_invariant(condition: bool) -> Option<()> { + condition.then_some(()) +} diff --git a/crates/ironrdp-cliprdr-format/src/html.rs b/crates/ironrdp-cliprdr-format/src/html.rs new file mode 100644 index 00000000..cf45252d --- /dev/null +++ b/crates/ironrdp-cliprdr-format/src/html.rs @@ -0,0 +1,149 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum HtmlError { + #[error("Invalid CF_HTML format")] + InvalidFormat, + #[error("Invalid UTF-8")] + InvalidUtf8(#[from] std::string::FromUtf8Error), + #[error("Failed to parse integer")] + InvalidInteger(#[from] std::num::ParseIntError), + #[error("Invalid integer conversion")] + InvalidConversion, +} + +/// Convert `CF_HTML` format to plain text. +pub fn cf_html_to_text(input: &[u8]) -> Result { + let mut start_fragment = None; + let mut end_fragment = None; + + let mut headers_cursor = input; + + let fragment = loop { + // Line split logic is manual instead of using BufReader::read_line because + // the line ending could be represented as `\r\n`, `\n` or even `\r`. + const ENDLINE_CONTROLS: &[u8] = &[b'\r', b'\n']; + + // Failed to find the end of the line + let end_pos = match headers_cursor.iter().position(|ch| ENDLINE_CONTROLS.contains(ch)) { + Some(pos) => pos, + None => return Err(HtmlError::InvalidFormat), + }; + + let line = String::from_utf8(headers_cursor[..end_pos].to_vec())?; + + let header_value_to_u32 = |value: &str| value.trim_start_matches('0').parse::(); + + match line.split_once(':') { + Some((key, value)) => match key { + "StartFragment" => { + start_fragment = Some(header_value_to_u32(value)?); + } + "EndFragment" => { + end_fragment = Some(header_value_to_u32(value)?); + } + _ => { + // We are not interested in other headers. + } + }, + None => { + if start_fragment.is_none() || end_fragment.is_none() { + // We reached the end of the headers, but we didn't find the required ones, + // so the format is invalid. + return Err(HtmlError::InvalidFormat); + } + } + }; + + if let (Some(start), Some(end)) = (start_fragment, end_fragment) { + let start = usize::try_from(start).map_err(|_| HtmlError::InvalidConversion)?; + let end = usize::try_from(end).map_err(|_| HtmlError::InvalidConversion)?; + + // Extract fragment from the original buffer. + if start > end || end > input.len() { + return Err(HtmlError::InvalidFormat); + } + + break String::from_utf8(input[start..end].to_vec())?; + } + + // INVARIANT: end_pos < headers_cursor.len() - 1 + // This is safe because we already checked above that the line ends with `\r` or `\n`. + #[allow(clippy::arithmetic_side_effects)] + { + // Go to the next line, skipping any leftover `LF` if CRLF was used. + let has_leftover_lf = end_pos + 1 != headers_cursor.len() + && headers_cursor[end_pos] == b'\r' + && headers_cursor[end_pos + 1] == b'\n'; + + if has_leftover_lf { + headers_cursor = &headers_cursor[end_pos + 2..]; + } else { + headers_cursor = &headers_cursor[end_pos + 1..]; + } + } + }; + + Ok(fragment) +} + +/// Convert plain text HTML to `CF_HTML` format. +pub fn text_to_cf_html(fragment: &str) -> Vec { + let mut buffer = Vec::new(); + + // INVARIANT: key.len() + value.len() + ":\r\n".len() < usize::MAX + // This is always true because we know `key` and `value` used in code below are + // short and their sizes are far from `usize::MAX`. + #[allow(clippy::arithmetic_side_effects)] + let mut write_header = |key: &str, value: &str| { + let size = key.len() + value.len() + ":\r\n".len(); + buffer.reserve(size); + + buffer.extend_from_slice(key.as_bytes()); + buffer.extend_from_slice(b":"); + let value_pos = buffer.len(); + buffer.extend_from_slice(value.as_bytes()); + buffer.extend_from_slice(b"\r\n"); + + value_pos + }; + + const POS_PLACEHOLDER: &str = "0000000000"; + + write_header("Version", "0.9"); + let start_html_placeholder_pos = write_header("StartHTML", POS_PLACEHOLDER); + let end_html_placeholder_pos = write_header("EndHTML", POS_PLACEHOLDER); + let start_fragment_placeholder_pos = write_header("StartFragment", POS_PLACEHOLDER); + let end_fragment_placeholder_pos = write_header("EndFragment", POS_PLACEHOLDER); + + let start_html_pos = buffer.len(); + buffer.extend_from_slice(b"\r\n\r\n"); + + let start_fragment_pos = buffer.len(); + buffer.extend_from_slice(fragment.as_bytes()); + + let end_fragment_pos = buffer.len(); + buffer.extend_from_slice(b"\r\n\r\n"); + + let end_html_pos = buffer.len(); + + let start_html_pos_value = format!("{:0>10}", start_html_pos); + let end_html_pos_value = format!("{:0>10}", end_html_pos); + let start_fragment_pos_value = format!("{:0>10}", start_fragment_pos); + let end_fragment_pos_value = format!("{:0>10}", end_fragment_pos); + + // INVARIANT: placeholder_pos + POS_PLACEHOLDER.len() < buffer.len() + // This is always valid because we know that placeholder is always present in the buffer + // fter the header is written and placeholder is within the bounds of the buffer. + #[allow(clippy::arithmetic_side_effects)] + let mut replace_placeholder = |placeholder_pos: usize, placeholder_value: &str| { + buffer[placeholder_pos..placeholder_pos + POS_PLACEHOLDER.len()].copy_from_slice(placeholder_value.as_bytes()); + }; + + replace_placeholder(start_html_placeholder_pos, &start_html_pos_value); + replace_placeholder(end_html_placeholder_pos, &end_html_pos_value); + replace_placeholder(start_fragment_placeholder_pos, &start_fragment_pos_value); + replace_placeholder(end_fragment_placeholder_pos, &end_fragment_pos_value); + + buffer +} diff --git a/crates/ironrdp-cliprdr-format/src/lib.rs b/crates/ironrdp-cliprdr-format/src/lib.rs new file mode 100644 index 00000000..367adc01 --- /dev/null +++ b/crates/ironrdp-cliprdr-format/src/lib.rs @@ -0,0 +1,4 @@ +#![doc = include_str!("../README.md")] + +pub mod bitmap; +pub mod html; diff --git a/crates/ironrdp-cliprdr/src/backend.rs b/crates/ironrdp-cliprdr/src/backend.rs index 3cab6393..c635973c 100644 --- a/crates/ironrdp-cliprdr/src/backend.rs +++ b/crates/ironrdp-cliprdr/src/backend.rs @@ -70,6 +70,18 @@ pub trait CliprdrBackend: AsAny + std::fmt::Debug + Send { /// client's clipboard prior to `CLIPRDR` SVC initialization. fn on_request_format_list(&mut self); + /// Called by [crate::Cliprdr] when copy sequence is finished. + /// This method is called after remote returns format list response. + /// + /// Usefull for the backend implementations which need to know when remote is ready to paste + /// previously advertised formats from the client. E.g. Web client uses this for + /// Firefox-specific logic to delay sending keyboard key events to prevent pasting the old + /// data from the clipboard. + /// + /// This method has default implementation which does nothing because it is not required for + /// most of the backends. + fn on_format_list_received(&mut self) {} + /// Adjusts [crate::Cliprdr] backend capabilities based on capabilities negotiated with a server. /// /// Called by [crate::Cliprdr] when capability negotiation is finished and server capabilities are diff --git a/crates/ironrdp-cliprdr/src/lib.rs b/crates/ironrdp-cliprdr/src/lib.rs index 91c11949..57a2a181 100644 --- a/crates/ironrdp-cliprdr/src/lib.rs +++ b/crates/ironrdp-cliprdr/src/lib.rs @@ -131,6 +131,8 @@ impl Cliprdr { } else { info!("CLIPRDR(clipboard) Remote has received format list successfully"); } + + self.backend.on_format_list_received(); } FormatListResponse::Fail => { return self.handle_error_transition(ClipboardError::FormatListRejected); diff --git a/crates/ironrdp-cliprdr/src/pdu/format_list.rs b/crates/ironrdp-cliprdr/src/pdu/format_list.rs index dbb9ad46..11770d51 100644 --- a/crates/ironrdp-cliprdr/src/pdu/format_list.rs +++ b/crates/ironrdp-cliprdr/src/pdu/format_list.rs @@ -14,7 +14,7 @@ use crate::pdu::{ClipboardPduFlags, PartialHeader}; /// [Standard clipboard formats](https://learn.microsoft.com/en-us/windows/win32/dataxchg/standard-clipboard-formats) /// defined by Microsoft are available as constants. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ClipboardFormatId(u32); +pub struct ClipboardFormatId(pub u32); impl ClipboardFormatId { /// Text format. Each line ends with a carriage return/linefeed (CR-LF) combination. @@ -150,6 +150,9 @@ impl ClipboardFormatName { /// data with [`crate::pdu::format_data::PackedFileList`] payload. pub const FILE_LIST: Self = Self::new_static("FileGroupDescriptorW"); + /// Special format defined by Windows to store HTML fragment in clipboard. + pub const HTML: Self = Self::new_static("HTML Format"); + pub fn new(name: impl Into>) -> Self { Self(name.into()) } @@ -167,8 +170,8 @@ impl ClipboardFormatName { /// Represents `CLIPRDR_SHORT_FORMAT_NAME` and `CLIPRDR_LONG_FORMAT_NAME` #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClipboardFormat { - id: ClipboardFormatId, - name: Option, + pub id: ClipboardFormatId, + pub name: Option, } impl ClipboardFormat { diff --git a/crates/ironrdp-fuzzing/Cargo.toml b/crates/ironrdp-fuzzing/Cargo.toml index c18ff24f..0151197b 100644 --- a/crates/ironrdp-fuzzing/Cargo.toml +++ b/crates/ironrdp-fuzzing/Cargo.toml @@ -14,4 +14,5 @@ arbitrary = { version = "1", features = ["derive"] } ironrdp-graphics.workspace = true ironrdp-pdu.workspace = true ironrdp-cliprdr.workspace = true -ironrdp-rdpdr.workspace = true \ No newline at end of file +ironrdp-rdpdr.workspace = true +ironrdp-cliprdr-format.workspace = true \ No newline at end of file diff --git a/crates/ironrdp-fuzzing/src/oracles/mod.rs b/crates/ironrdp-fuzzing/src/oracles/mod.rs index 91919ffa..82b02c28 100644 --- a/crates/ironrdp-fuzzing/src/oracles/mod.rs +++ b/crates/ironrdp-fuzzing/src/oracles/mod.rs @@ -118,3 +118,17 @@ pub fn rdp6_decode_bitmap_stream_to_rgb24(input: &BitmapInput<'_>) { input.height as usize, ); } + +pub fn cliprdr_format(input: &[u8]) { + use ironrdp_cliprdr_format::bitmap::{dib_to_png, dibv5_to_png, png_to_cf_dib, png_to_cf_dibv5}; + use ironrdp_cliprdr_format::html::{cf_html_to_text, text_to_cf_html}; + + let _ = png_to_cf_dib(input); + let _ = png_to_cf_dibv5(input); + + let _ = dib_to_png(input); + let _ = dibv5_to_png(input); + + let _ = cf_html_to_text(input); + let _ = text_to_cf_html(String::from_utf8_lossy(input).as_ref()); +} diff --git a/crates/ironrdp-pdu/src/cursor.rs b/crates/ironrdp-pdu/src/cursor.rs index b14251ba..c8776630 100644 --- a/crates/ironrdp-pdu/src/cursor.rs +++ b/crates/ironrdp-pdu/src/cursor.rs @@ -408,6 +408,12 @@ impl<'a> WriteCursor<'a> { self.write_array(value.to_be_bytes()) } + #[inline] + #[track_caller] + pub fn write_i32(&mut self, value: i32) { + self.write_array(value.to_le_bytes()) + } + #[inline] #[track_caller] pub fn write_u64(&mut self, value: u64) { diff --git a/crates/ironrdp-pdu/src/lib.rs b/crates/ironrdp-pdu/src/lib.rs index 1fa4b17d..1580bfd2 100644 --- a/crates/ironrdp-pdu/src/lib.rs +++ b/crates/ironrdp-pdu/src/lib.rs @@ -7,6 +7,7 @@ use core::fmt; use cursor::WriteCursor; +#[cfg(feature = "alloc")] use write_buf::WriteBuf; use crate::cursor::ReadCursor; diff --git a/crates/ironrdp-rdpdr/src/backend/mod.rs b/crates/ironrdp-rdpdr/src/backend/mod.rs index 9afd067e..8bb4f686 100644 --- a/crates/ironrdp-rdpdr/src/backend/mod.rs +++ b/crates/ironrdp-rdpdr/src/backend/mod.rs @@ -8,7 +8,7 @@ use ironrdp_svc::AsAny; use crate::pdu::efs::{DeviceControlRequest, ServerDeviceAnnounceResponse, ServerDriveIoRequest}; use crate::pdu::esc::{ScardCall, ScardIoCtlCode}; -/// OS-specific device redirection backend inteface. +/// OS-specific device redirection backend interface. pub trait RdpdrBackend: AsAny + fmt::Debug + Send { fn handle_server_device_announce_response(&mut self, pdu: ServerDeviceAnnounceResponse) -> PduResult<()>; fn handle_scard_call(&mut self, req: DeviceControlRequest, call: ScardCall) -> PduResult<()>; diff --git a/crates/ironrdp-rdpdr/src/lib.rs b/crates/ironrdp-rdpdr/src/lib.rs index 67420fd4..18424e26 100644 --- a/crates/ironrdp-rdpdr/src/lib.rs +++ b/crates/ironrdp-rdpdr/src/lib.rs @@ -23,10 +23,9 @@ use pdu::RdpdrPdu; pub mod backend; pub mod pdu; -use crate::pdu::efs::ServerDriveIoRequest; - pub use self::backend::noop::NoopRdpdrBackend; pub use self::backend::RdpdrBackend; +use crate::pdu::efs::ServerDriveIoRequest; /// The RDPDR channel as specified in [\[MS-RDPEFS\]]. /// diff --git a/crates/ironrdp-testsuite-core/Cargo.toml b/crates/ironrdp-testsuite-core/Cargo.toml index 9247f96e..3c25a006 100644 --- a/crates/ironrdp-testsuite-core/Cargo.toml +++ b/crates/ironrdp-testsuite-core/Cargo.toml @@ -25,6 +25,7 @@ paste = "1" png = "0.17" hex = "0.4" ironrdp-cliprdr.workspace = true +ironrdp-cliprdr-format.workspace = true ironrdp-connector.workspace = true ironrdp-fuzzing.workspace = true ironrdp-graphics.workspace = true diff --git a/crates/ironrdp-testsuite-core/test_data/pdu/clipboard/cf_dib.pdu b/crates/ironrdp-testsuite-core/test_data/pdu/clipboard/cf_dib.pdu new file mode 100644 index 0000000000000000000000000000000000000000..2fd8246bf8d9008b0f950368a6f086f5f0584c0c GIT binary patch literal 340 zcmdO3U|`^9U|?WnU|?WmP+$N79Yzp|ivL3a2gCpW4F5&J7{mtQ|2z!;K{N<6FfjZF zVGtXt1x|s)koAJ}!7x~k1ic{jAiW?Ogh6(KFo+Ge5Xu0FVY3%34z&}TUXVNtgY?7L M==Q?IK{Uup0NK;A=l}o! literal 0 HcmV?d00001 diff --git a/crates/ironrdp-testsuite-core/test_data/pdu/clipboard/cf_dibv5.pdu b/crates/ironrdp-testsuite-core/test_data/pdu/clipboard/cf_dibv5.pdu new file mode 100644 index 0000000000000000000000000000000000000000..58bb0306e37b5e177c9aa386f061975df4beaa22 GIT binary patch literal 424 zcmb;whX0~q z3}S=ue;$VaAR2@jz&1np=th9$k@SM}!7x~k1ic{jAiW?OgvqfNSszG0ln-|{h!4Xc PKf~DQ_QJ$LG)OZ5X<~2E literal 0 HcmV?d00001 diff --git a/crates/ironrdp-testsuite-core/test_data/pdu/clipboard/cf_html.pdu b/crates/ironrdp-testsuite-core/test_data/pdu/clipboard/cf_html.pdu new file mode 100644 index 0000000000000000000000000000000000000000..8500deb0da6cd5631b7c190e20ada357cb274ec3 GIT binary patch literal 892 zcmWGbEh^5;&$BYnv*hIpE=epZ@d)wtu`&PwLo*{@F4w#igouTuAyk!HQDS;-YF-Ic zrKK5IC90@}sVOg4aDHh~a%yOhk5xuVNkOrdzJ6++UU_DAW zn_rR|?~+=aU6NlAA5fHElAoNP!^>rpQIeZuXOonlQfbG_WuvI83w9>VDqUSWnnopers").is_err()); + // Out of bounds headers + assert!(cf_html_to_text(b"StartFragment:999\r\nEndFragment:9999\r\nnopers").is_err()); +} + +#[test] +fn test_cf_html_to_text() { + let input = include_bytes!("../../test_data/pdu/clipboard/cf_html.pdu"); + let actual = cf_html_to_text(input).unwrap(); + + // Validate that the output is valid HTML + assert!(actual.starts_with("Remote Desktop Protocol")); + assert!(actual.ends_with("")); + + // Validate roundtrip + let mut cf_html = text_to_cf_html(&actual); + let roundtrip_text_html = cf_html_to_text(&cf_html).unwrap(); + assert_eq!(actual, roundtrip_text_html); + + // Add some padding (CF_HTML is not null-terminated, we need to work with data which is + // potentially padded with arbitrary fill bytes). + cf_html.extend_from_slice(&[0xFFu8; 10]); + + let roundtrip_text_html = cf_html_to_text(&cf_html).unwrap(); + + assert_eq!(actual, roundtrip_text_html); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard.rs b/crates/ironrdp-testsuite-core/tests/clipboard/mod.rs similarity index 96% rename from crates/ironrdp-testsuite-core/tests/clipboard.rs rename to crates/ironrdp-testsuite-core/tests/clipboard/mod.rs index f7c9e9b4..a863b4f6 100644 --- a/crates/ironrdp-testsuite-core/tests/clipboard.rs +++ b/crates/ironrdp-testsuite-core/tests/clipboard/mod.rs @@ -1,3 +1,5 @@ +mod format; + use expect_test::expect; use ironrdp_cliprdr::pdu::{ Capabilities, CapabilitySet, ClipboardFormat, ClipboardFormatId, ClipboardFormatName, @@ -135,7 +137,7 @@ encode_decode_test! { #[test] fn client_temp_dir_encode_decode_ms_1() { // Test blob from [MS-RDPECLIP] - let input = include_bytes!("../test_data/pdu/clipboard/client_temp_dir.pdu"); + let input = include_bytes!("../../test_data/pdu/clipboard/client_temp_dir.pdu"); let decoded_pdu: ClipboardPdu = ironrdp_pdu::decode(input).unwrap(); @@ -154,7 +156,7 @@ fn client_temp_dir_encode_decode_ms_1() { #[test] fn format_list_ms_1() { // Test blob from [MS-RDPECLIP] - let input = include_bytes!("../test_data/pdu/clipboard/format_list.pdu"); + let input = include_bytes!("../../test_data/pdu/clipboard/format_list.pdu"); let decoded_pdu: ClipboardPdu = ironrdp_pdu::decode(input).unwrap(); @@ -208,7 +210,7 @@ fn format_list_ms_1() { #[test] fn format_list_ms_2() { // Test blob from [MS-RDPECLIP] - let input = include_bytes!("../test_data/pdu/clipboard/format_list_2.pdu"); + let input = include_bytes!("../../test_data/pdu/clipboard/format_list_2.pdu"); let decoded_pdu: ClipboardPdu = ironrdp_pdu::decode(input).unwrap(); @@ -342,7 +344,7 @@ fn format_list_all_encodings() { #[test] fn metafile_pdu_ms() { // Test blob from [MS-RDPECLIP] - let input = include_bytes!("../test_data/pdu/clipboard/metafile.pdu"); + let input = include_bytes!("../../test_data/pdu/clipboard/metafile.pdu"); let decoded_pdu: ClipboardPdu = ironrdp_pdu::decode(input).unwrap(); @@ -367,7 +369,7 @@ fn metafile_pdu_ms() { #[test] fn palette_pdu_ms() { // Test blob from [MS-RDPECLIP] - let input = include_bytes!("../test_data/pdu/clipboard/palette.pdu"); + let input = include_bytes!("../../test_data/pdu/clipboard/palette.pdu"); let decoded_pdu: ClipboardPdu = ironrdp_pdu::decode(input).unwrap(); @@ -393,7 +395,7 @@ fn palette_pdu_ms() { #[test] fn file_list_pdu_ms() { // Test blob from [MS-RDPECLIP] - let input = include_bytes!("../test_data/pdu/clipboard/file_list.pdu"); + let input = include_bytes!("../../test_data/pdu/clipboard/file_list.pdu"); let decoded_pdu: ClipboardPdu = ironrdp_pdu::decode(input).unwrap(); diff --git a/crates/ironrdp-web/Cargo.toml b/crates/ironrdp-web/Cargo.toml index 536bd279..22c81070 100644 --- a/crates/ironrdp-web/Cargo.toml +++ b/crates/ironrdp-web/Cargo.toml @@ -24,7 +24,8 @@ panic_hook = ["dep:console_error_panic_hook"] [dependencies] # Protocols -ironrdp = { workspace = true, features = ["input", "graphics", "dvc"] } +ironrdp = { workspace = true, features = ["input", "graphics", "dvc", "cliprdr", "svc"] } +ironrdp-cliprdr-format = { workspace = true } ironrdp-futures.workspace = true ironrdp-rdcleanpath.workspace = true diff --git a/crates/ironrdp-web/src/clipboard/mod.rs b/crates/ironrdp-web/src/clipboard/mod.rs new file mode 100644 index 00000000..db60696c --- /dev/null +++ b/crates/ironrdp-web/src/clipboard/mod.rs @@ -0,0 +1,566 @@ +//! This module implements browser-based clipboard backend for CLIPRDR SVC + +mod transaction; + +use std::collections::HashMap; + +use futures_channel::mpsc; +use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackend}; +use ironrdp::cliprdr::pdu::{ + ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags, FileContentsRequest, + FileContentsResponse, FormatDataRequest, FormatDataResponse, LockDataId, +}; +use ironrdp::svc::impl_as_any; +use ironrdp_cliprdr_format::bitmap::{dib_to_png, dibv5_to_png, png_to_cf_dibv5}; +use ironrdp_cliprdr_format::html::{cf_html_to_text, text_to_cf_html}; +pub(crate) use transaction::ClipboardTransaction; +use transaction::{ClipboardContent, ClipboardContentValue}; +use wasm_bindgen::prelude::*; + +use crate::session::RdpInputEvent; + +const MIME_TEXT: &str = "text/plain"; +const MIME_HTML: &str = "text/html"; +const MIME_PNG: &str = "image/png"; + +#[derive(Clone, Copy)] +struct ClientFormatDescriptor { + id: ClipboardFormatId, + name: &'static str, +} + +impl ClientFormatDescriptor { + const fn new(id: ClipboardFormatId, name: &'static str) -> Self { + Self { id, name } + } +} + +impl From for ClipboardFormat { + fn from(descriptor: ClientFormatDescriptor) -> Self { + ClipboardFormat::new(descriptor.id).with_name(ClipboardFormatName::new_static(descriptor.name)) + } +} + +const FORMAT_WIN_HTML_ID: ClipboardFormatId = ClipboardFormatId(0xC001); +const FORMAT_MIME_HTML_ID: ClipboardFormatId = ClipboardFormatId(0xC002); +const FORMAT_PNG_ID: ClipboardFormatId = ClipboardFormatId(0xC003); +const FORMAT_MIME_PNG_ID: ClipboardFormatId = ClipboardFormatId(0xC004); + +const FORMAT_WIN_HTML_NAME: &str = "HTML Format"; +const FORMAT_MIME_HTML_NAME: &str = "text/html"; +const FORMAT_PNG_NAME: &str = "PNG"; +const FORMAT_MIME_PNG_NAME: &str = "image/png"; + +const FORMAT_WIN_HTML: ClientFormatDescriptor = ClientFormatDescriptor::new(FORMAT_WIN_HTML_ID, FORMAT_WIN_HTML_NAME); +const FORMAT_MIME_HTML: ClientFormatDescriptor = + ClientFormatDescriptor::new(FORMAT_MIME_HTML_ID, FORMAT_MIME_HTML_NAME); +const FORMAT_PNG: ClientFormatDescriptor = ClientFormatDescriptor::new(FORMAT_PNG_ID, FORMAT_PNG_NAME); +const FORMAT_MIME_PNG: ClientFormatDescriptor = ClientFormatDescriptor::new(FORMAT_MIME_PNG_ID, FORMAT_MIME_PNG_NAME); + +/// Message proxy used to send clipboard-related messages to the application main event loop +#[derive(Debug, Clone)] +pub(crate) struct WasmClipboardMessageProxy { + tx: mpsc::UnboundedSender, +} + +impl WasmClipboardMessageProxy { + pub(crate) fn new(tx: mpsc::UnboundedSender) -> Self { + Self { tx } + } + + /// Send messages which require action on CLIPRDR SVC + pub(crate) fn send_cliprdr_message(&self, message: ClipboardMessage) { + if self.tx.unbounded_send(RdpInputEvent::Cliprdr(message)).is_err() { + error!("Failed to send os clipboard message, receiver is closed"); + } + } + + /// Send messages which require action on wasm clipboard backend + pub(crate) fn send_backend_message(&self, message: WasmClipboardBackendMessage) { + if self + .tx + .unbounded_send(RdpInputEvent::ClipboardBackend(message)) + .is_err() + { + error!("Failed to send os clipboard message, receiver is closed"); + } + } +} + +/// Messages sent by the JS code or CLIPRDR to the backend implementation. +#[derive(Debug)] +pub(crate) enum WasmClipboardBackendMessage { + LocalClipboardChanged(ClipboardTransaction), + RemoteDataRequest(ClipboardFormatId), + + RemoteClipboardChanged(Vec), + RemoteDataResponse(FormatDataResponse<'static>), + + FormatListReceived, + ForceClipboardUpdate, +} + +/// Clipboard backend implementation for web. This object should be created once per session and +/// kept alive until session is terminated. +pub(crate) struct WasmClipboard { + local_clipboard: Option, + remote_clipborad: ClipboardTransaction, + + remote_mapping: HashMap, + remote_formats_to_read: Vec, + + proxy: WasmClipboardMessageProxy, + js_callbacks: JsClipboardCallbacks, +} + +/// Callbacks, required to interact with JS code from within the backend. +pub(crate) struct JsClipboardCallbacks { + pub(crate) on_remote_clipboard_changed: js_sys::Function, + pub(crate) on_remote_received_format_list: Option, + pub(crate) on_force_clipboard_update: Option, +} + +impl WasmClipboard { + pub(crate) fn new(message_proxy: WasmClipboardMessageProxy, js_callbacks: JsClipboardCallbacks) -> Self { + Self { + local_clipboard: None, + remote_clipborad: ClipboardTransaction::new(), + proxy: message_proxy, + js_callbacks, + + remote_mapping: HashMap::new(), + remote_formats_to_read: Vec::new(), + } + } + + /// Returns CLIPRDR backend implementation + pub(crate) fn backend(&self) -> WasmClipboardBackend { + WasmClipboardBackend { + proxy: self.proxy.clone(), + } + } + + fn handle_local_clipboard_changed( + &mut self, + transaction: ClipboardTransaction, + ) -> anyhow::Result> { + let mut formats = Vec::new(); + transaction.contents().iter().for_each(|content| { + match content.mime_type() { + MIME_TEXT => formats.push(ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)), + MIME_HTML => { + formats.extend([ + // We don't provide CF_TEXT, because it could be synthesized from + // CF_UNICODETEXT on the remote side. + ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT), + FORMAT_WIN_HTML.into(), + FORMAT_MIME_HTML.into(), + ]); + } + MIME_PNG => { + formats.extend([ + // We don't provide CF_DIB, because it could be synthesized from + // CF_DIBV5 on the remote side. + ClipboardFormat::new(ClipboardFormatId::CF_DIBV5), + FORMAT_PNG.into(), + FORMAT_MIME_PNG.into(), + ]); + } + _ => {} + }; + }); + + self.local_clipboard = Some(transaction); + + trace!("Sending clipboard formats: {:?}", formats); + + Ok(formats) + } + + fn process_remote_data_request( + &mut self, + format: ClipboardFormatId, + ) -> anyhow::Result> { + // Transaction is not set, bail! + let transaction = if let Some(transaction) = &self.local_clipboard { + transaction + } else { + anyhow::bail!("Local clipboard is empty"); + }; + + let find_content_by_mime = |mime: &str| { + transaction + .contents() + .iter() + .find(|content| content.mime_type() == mime) + }; + + let find_text_content_by_mime = |mime: &str| { + find_content_by_mime(mime) + .and_then(|content| { + if let ClipboardContentValue::Text(text) = content.value() { + Some(text.as_str()) + } else { + None + } + }) + .ok_or_else(|| anyhow::anyhow!("Failed to find `{mime}` in client clipboard")) + }; + + let find_binary_content_by_mime = |mime: &str| { + find_content_by_mime(mime) + .and_then(|content| { + if let ClipboardContentValue::Binary(binary) = content.value() { + Some(binary.as_slice()) + } else { + None + } + }) + .ok_or_else(|| anyhow::anyhow!("Failed to find `{mime}` in client clipboard")) + }; + + let response = match format { + ClipboardFormatId::CF_UNICODETEXT => { + let text = find_text_content_by_mime(MIME_TEXT)?; + FormatDataResponse::new_unicode_string(text) + } + FORMAT_WIN_HTML_ID => { + let text = find_text_content_by_mime(MIME_HTML)?; + let buffer = text_to_cf_html(text); + FormatDataResponse::new_data(buffer) + } + FORMAT_MIME_HTML_ID => { + let text = find_text_content_by_mime(MIME_HTML)?; + FormatDataResponse::new_string(text) + } + ClipboardFormatId::CF_DIBV5 => { + let png_data = find_binary_content_by_mime(MIME_PNG)?; + let buffer = png_to_cf_dibv5(png_data)?; + FormatDataResponse::new_data(buffer) + } + FORMAT_MIME_PNG_ID | FORMAT_PNG_ID => { + let png_data = find_binary_content_by_mime(MIME_PNG)?; + FormatDataResponse::new_data(png_data) + } + _ => { + anyhow::bail!("Unknown format id requested: {}", format.value()); + } + }; + + Ok(response.into_owned()) + } + + fn process_remote_clipboard_changed( + &mut self, + formats: Vec, + ) -> anyhow::Result> { + self.remote_formats_to_read.clear(); + self.remote_clipborad.clear(); + + let is_format_name_equal = |format: &ClipboardFormat, name: &str| { + format + .name() + .map(|actual: &ClipboardFormatName| actual.value() == name) + .unwrap_or(false) + }; + + for format in &formats { + if format.id().is_registrered() { + if let Some(name) = format.name() { + const SUPPORTED_FORMATS: &[&str] = &[ + FORMAT_WIN_HTML.name, + FORMAT_MIME_HTML.name, + FORMAT_PNG.name, + FORMAT_MIME_PNG.name, + ]; + + if !SUPPORTED_FORMATS.iter().any(|supported| *supported == name.value()) { + // Unknown format + continue; + } + + // Skip inferior formats (e.g. raw image/png is better than encoded CF_DIB; + // text/html is better than CF_HTML). + // + // Web client do not have delay-rendering, so we should skip formats that + // are not relevant for transferred over the network. + let skip_win_html = is_format_name_equal(format, FORMAT_WIN_HTML.name) + && formats + .iter() + .any(|format| is_format_name_equal(format, FORMAT_MIME_HTML.name)); + + let skip_mime_png = is_format_name_equal(format, FORMAT_MIME_PNG.name) + && formats + .iter() + .any(|format| is_format_name_equal(format, FORMAT_PNG.name)); + + if skip_win_html || skip_mime_png { + continue; + } + + self.remote_mapping.insert(format.id(), name.value().to_owned()); + } + } else { + const SUPPORTED_FORMATS: &[ClipboardFormatId] = &[ + ClipboardFormatId::CF_UNICODETEXT, + ClipboardFormatId::CF_DIB, + ClipboardFormatId::CF_DIBV5, + ]; + + if !SUPPORTED_FORMATS.iter().any(|supported| *supported == format.id()) { + // Unknown format + continue; + } + + let skip_dib = format.id() == ClipboardFormatId::CF_DIB + && formats.iter().any(|format| { + format.id() == ClipboardFormatId::CF_DIBV5 + || is_format_name_equal(format, FORMAT_MIME_PNG.name) + || is_format_name_equal(format, FORMAT_PNG.name) + }); + + let skip_dibv5 = format.id() == ClipboardFormatId::CF_DIBV5 + && formats.iter().any(|format| { + is_format_name_equal(format, FORMAT_MIME_PNG.name) + || is_format_name_equal(format, FORMAT_PNG.name) + }); + + if skip_dib || skip_dibv5 { + continue; + } + } + + self.remote_formats_to_read.push(format.id()); + } + + Ok(self.remote_formats_to_read.last().copied()) + } + + fn process_remote_data_response(&mut self, response: FormatDataResponse<'_>) -> anyhow::Result<()> { + let pending_format = match self.remote_formats_to_read.pop() { + Some(format) => format, + None => { + warn!("Remote returned format data, but no formats were requested"); + return Ok(()); + } + }; + + if response.is_error() { + // Format is not available anymore + return Ok(()); + } + + let content = match pending_format { + ClipboardFormatId::CF_UNICODETEXT => match response.to_unicode_string() { + Ok(text) => Some(ClipboardContent::new_text(MIME_TEXT, &text)), + Err(err) => { + error!("CF_UNICODETEXT decode error: {}", err); + None + } + }, + ClipboardFormatId::CF_DIB => match dib_to_png(response.data()) { + Ok(png) => Some(ClipboardContent::new_binary(MIME_PNG, &png)), + Err(err) => { + warn!("DIB decode error: {}", err); + None + } + }, + ClipboardFormatId::CF_DIBV5 => match dibv5_to_png(response.data()) { + Ok(png) => Some(ClipboardContent::new_binary(MIME_PNG, &png)), + Err(err) => { + warn!("DIBv5 decode error: {}", err); + None + } + }, + registered => { + let format_name = self.remote_mapping.get(®istered).map(|s| s.as_str()); + match format_name { + Some(FORMAT_WIN_HTML_NAME) => match cf_html_to_text(response.data()) { + Ok(text) => Some(ClipboardContent::new_text(MIME_HTML, &text)), + Err(err) => { + warn!("CF_HTML decode error: {}", err); + None + } + }, + Some(FORMAT_MIME_HTML_NAME) => match response.to_string() { + Ok(text) => Some(ClipboardContent::new_text(MIME_HTML, &text)), + Err(err) => { + warn!("text/html decode error: {}", err); + None + } + }, + Some(FORMAT_MIME_PNG_NAME) | Some(FORMAT_PNG_NAME) => { + Some(ClipboardContent::new_binary(MIME_PNG, response.data())) + } + _ => { + // Not supported format + None + } + } + } + }; + + if let Some(content) = content { + self.remote_clipborad.add_content(content); + } + + // Request next format + if let Some(format) = self.remote_formats_to_read.last() { + // Request next format + self.proxy + .send_cliprdr_message(ClipboardMessage::SendInitiatePaste(*format)); + } else { + // All formats were read, send clipboard to JS + let transaction = std::mem::take(&mut self.remote_clipborad); + if transaction.is_empty() { + return Ok(()); + } + // Set clipboard when all formats were read + self.js_callbacks + .on_remote_clipboard_changed + .call1(&JsValue::NULL, &JsValue::from(transaction)) + .expect("Failed to call JS callback"); + } + Ok(()) + } + + /// Process backend event. This method should be called from the main event loop. + pub(crate) fn process_event(&mut self, event: WasmClipboardBackendMessage) -> anyhow::Result<()> { + match event { + WasmClipboardBackendMessage::LocalClipboardChanged(transaction) => { + match self.handle_local_clipboard_changed(transaction) { + Ok(formats) => { + self.proxy + .send_cliprdr_message(ClipboardMessage::SendInitiateCopy(formats)); + } + Err(err) => { + // Not a critical error, we could skip single clipboard update + error!("Failed to handle local clipboard change: {}", err); + } + } + } + WasmClipboardBackendMessage::RemoteDataRequest(format) => { + let message = match self.process_remote_data_request(format) { + Ok(message) => message, + Err(err) => { + // Not a critical error, but we should notify remote about error + error!("Failed to process remote data request: {}", err); + FormatDataResponse::new_error() + } + }; + self.proxy + .send_cliprdr_message(ClipboardMessage::SendFormatData(message)); + } + WasmClipboardBackendMessage::RemoteClipboardChanged(formats) => { + match self.process_remote_clipboard_changed(formats) { + Ok(Some(format)) => { + // We start querying formats right away. This is due absence of + // delay-rendering in web client. + self.proxy + .send_cliprdr_message(ClipboardMessage::SendInitiatePaste(format)); + } + Ok(None) => { + // No formats to query + } + Err(err) => { + error!("Failed to process remote clipboard change: {}", err); + } + } + } + WasmClipboardBackendMessage::RemoteDataResponse(formats) => { + match self.process_remote_data_response(formats) { + Ok(()) => {} + Err(err) => { + error!("Failed to process remote data response: {}", err); + } + } + } + WasmClipboardBackendMessage::FormatListReceived => { + if let Some(callback) = self.js_callbacks.on_remote_received_format_list.as_mut() { + callback.call0(&JsValue::NULL).expect("Failed to call JS callback"); + } + } + WasmClipboardBackendMessage::ForceClipboardUpdate => { + if let Some(callback) = self.js_callbacks.on_force_clipboard_update.as_mut() { + callback.call0(&JsValue::NULL).expect("Failed to call JS callback"); + } else { + // If no initial clipboard callback was set, send empty format list instead + return self.process_event(WasmClipboardBackendMessage::LocalClipboardChanged( + ClipboardTransaction::new(), + )); + } + } + }; + + Ok(()) + } +} + +/// CLIPRDR backend implementation for web. This object could be instantiated via [`WasmClipboard`] +/// to pass it to CLIPRDR SVC constructor. +#[derive(Debug)] +pub(crate) struct WasmClipboardBackend { + proxy: WasmClipboardMessageProxy, +} + +impl WasmClipboardBackend { + fn send_event(&self, event: WasmClipboardBackendMessage) { + self.proxy.send_backend_message(event); + } +} + +impl_as_any!(WasmClipboardBackend); + +impl CliprdrBackend for WasmClipboardBackend { + fn temporary_directory(&self) -> &str { + ".cliprdr" + } + + fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { + // No additional capabilities yet + ClipboardGeneralCapabilityFlags::empty() + } + + fn on_request_format_list(&mut self) { + // Initial clipboard is assumed to be empty on WASM (TODO: This is only relevant for Firefox?) + self.send_event(WasmClipboardBackendMessage::ForceClipboardUpdate); + } + + fn on_format_list_received(&mut self) { + self.send_event(WasmClipboardBackendMessage::FormatListReceived); + } + + fn on_process_negotiated_capabilities(&mut self, _: ClipboardGeneralCapabilityFlags) { + // No additional capabilities yet + } + + fn on_remote_copy(&mut self, available_formats: &[ClipboardFormat]) { + self.send_event(WasmClipboardBackendMessage::RemoteClipboardChanged( + available_formats.to_vec(), + )); + } + + fn on_format_data_request(&mut self, request: FormatDataRequest) { + self.send_event(WasmClipboardBackendMessage::RemoteDataRequest(request.format)); + } + + fn on_format_data_response(&mut self, response: FormatDataResponse<'_>) { + self.send_event(WasmClipboardBackendMessage::RemoteDataResponse(response.into_owned())); + } + + fn on_file_contents_request(&mut self, _request: FileContentsRequest) { + // File transfer not implemented yet + } + + fn on_file_contents_response(&mut self, _response: FileContentsResponse<'_>) { + // File transfer not implemented yet + } + + fn on_lock(&mut self, _data_id: LockDataId) { + // File transfer not implemented yet + } + + fn on_unlock(&mut self, _data_id: LockDataId) { + // File transfer not implemented yet + } +} diff --git a/crates/ironrdp-web/src/clipboard/transaction.rs b/crates/ironrdp-web/src/clipboard/transaction.rs new file mode 100644 index 00000000..a6d5f921 --- /dev/null +++ b/crates/ironrdp-web/src/clipboard/transaction.rs @@ -0,0 +1,110 @@ +use wasm_bindgen::prelude::*; + +/// Object which represents complete clipboard transaction with multiple MIME types. +#[wasm_bindgen] +#[derive(Debug, Default, Clone)] +pub struct ClipboardTransaction { + contents: Vec, +} + +impl ClipboardTransaction { + pub fn contents(&self) -> &[ClipboardContent] { + &self.contents + } + + pub fn clear(&mut self) { + self.contents.clear(); + } +} + +#[wasm_bindgen] +impl ClipboardTransaction { + pub fn new() -> Self { + Self { contents: Vec::new() } + } + + pub fn add_content(&mut self, content: ClipboardContent) { + self.contents.push(content); + } + + pub fn is_empty(&self) -> bool { + self.contents.is_empty() + } + + #[wasm_bindgen(js_name = content)] + pub fn js_contents(&self) -> js_sys::Array { + js_sys::Array::from_iter( + self.contents + .iter() + .map(|content: &ClipboardContent| JsValue::from(content.clone())), + ) + } +} + +impl FromIterator for ClipboardTransaction { + fn from_iter>(iter: T) -> Self { + Self { + contents: iter.into_iter().collect(), + } + } +} + +#[derive(Debug, Clone)] +pub enum ClipboardContentValue { + Text(String), + Binary(Vec), +} + +impl ClipboardContentValue { + pub fn js_value(&self) -> JsValue { + match self { + ClipboardContentValue::Text(text) => JsValue::from_str(text), + ClipboardContentValue::Binary(binary) => js_sys::Uint8Array::from(binary.as_slice()).into(), + } + } +} + +/// Object which represents single clipboard format represented standard MIME type. +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct ClipboardContent { + mime_type: String, + value: ClipboardContentValue, +} + +#[wasm_bindgen] +impl ClipboardContent { + pub fn new_text(mime_type: &str, text: &str) -> Self { + Self { + mime_type: mime_type.into(), + value: ClipboardContentValue::Text(text.to_owned()), + } + } + + pub fn new_binary(mime_type: &str, binary: &[u8]) -> Self { + Self { + mime_type: mime_type.into(), + value: ClipboardContentValue::Binary(binary.to_vec()), + } + } + + #[wasm_bindgen(js_name = mime_type)] + pub fn js_mime_type(&self) -> String { + self.mime_type.clone() + } + + #[wasm_bindgen(js_name = value)] + pub fn js_value(&self) -> JsValue { + self.value.js_value() + } +} + +impl ClipboardContent { + pub fn mime_type(&self) -> &str { + &self.mime_type + } + + pub fn value(&self) -> &ClipboardContentValue { + &self.value + } +} diff --git a/crates/ironrdp-web/src/lib.rs b/crates/ironrdp-web/src/lib.rs index ec4b39bf..1c8a8cfe 100644 --- a/crates/ironrdp-web/src/lib.rs +++ b/crates/ironrdp-web/src/lib.rs @@ -11,6 +11,7 @@ extern crate time as _; extern crate tracing; mod canvas; +mod clipboard; mod error; mod image; mod input; diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index 2c88c8c0..e114554d 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -8,6 +8,8 @@ use futures_util::io::{ReadHalf, WriteHalf}; use futures_util::{select, AsyncReadExt as _, AsyncWriteExt as _, FutureExt as _, StreamExt as _}; use gloo_net::websocket; use gloo_net::websocket::futures::WebSocket; +use ironrdp::cliprdr::backend::ClipboardMessage; +use ironrdp::cliprdr::Cliprdr; use ironrdp::connector::{self, ClientConnector, Credentials}; use ironrdp::graphics::image_processing::PixelFormat; use ironrdp::pdu::input::fast_path::FastPathInputEvent; @@ -20,12 +22,13 @@ use wasm_bindgen_futures::spawn_local; use web_sys::HtmlCanvasElement; use crate::canvas::Canvas; +use crate::clipboard::{ClipboardTransaction, WasmClipboard, WasmClipboardBackend, WasmClipboardBackendMessage}; use crate::error::{IronRdpError, IronRdpErrorKind}; use crate::image::extract_partial_image; use crate::input::InputTransaction; use crate::network_client::WasmNetworkClientFactory; use crate::websocket::WebSocketCompat; -use crate::DesktopSize; +use crate::{clipboard, DesktopSize}; const DEFAULT_WIDTH: u16 = 1280; const DEFAULT_HEIGHT: u16 = 720; @@ -50,6 +53,9 @@ struct SessionBuilderInner { hide_pointer_callback_context: Option, show_pointer_callback: Option, show_pointer_callback_context: Option, + remote_clipboard_changed_callback: Option, + remote_received_format_list_callback: Option, + force_clipboard_update_callback: Option, } impl Default for SessionBuilderInner { @@ -73,6 +79,9 @@ impl Default for SessionBuilderInner { hide_pointer_callback_context: None, show_pointer_callback: None, show_pointer_callback_context: None, + remote_clipboard_changed_callback: None, + remote_received_format_list_callback: None, + force_clipboard_update_callback: None, } } } @@ -165,6 +174,24 @@ impl SessionBuilder { self.clone() } + /// Optional + pub fn remote_clipboard_changed_callback(&self, callback: js_sys::Function) -> SessionBuilder { + self.0.borrow_mut().remote_clipboard_changed_callback = Some(callback); + self.clone() + } + + /// Optional + pub fn remote_received_format_list_callback(&self, callback: js_sys::Function) -> SessionBuilder { + self.0.borrow_mut().remote_received_format_list_callback = Some(callback); + self.clone() + } + + /// Optional + pub fn force_clipboard_update_callback(&self, callback: js_sys::Function) -> SessionBuilder { + self.0.borrow_mut().force_clipboard_update_callback = Some(callback); + self.clone() + } + pub async fn connect(&self) -> Result { let ( username, @@ -181,6 +208,9 @@ impl SessionBuilder { hide_pointer_callback_context, show_pointer_callback, show_pointer_callback_context, + remote_clipboard_changed_callback, + remote_received_format_list_callback, + force_clipboard_update_callback, ); { @@ -213,12 +243,28 @@ impl SessionBuilder { .show_pointer_callback_context .clone() .context("show_pointer_callback_context missing")?; + remote_clipboard_changed_callback = inner.remote_clipboard_changed_callback.clone(); + remote_received_format_list_callback = inner.remote_received_format_list_callback.clone(); + force_clipboard_update_callback = inner.force_clipboard_update_callback.clone(); } info!("Connect to RDP host"); let config = build_config(username, password, server_domain, client_name, desktop_size); + let (input_events_tx, input_events_rx) = mpsc::unbounded(); + + let clipboard = remote_clipboard_changed_callback.clone().map(|callback| { + WasmClipboard::new( + clipboard::WasmClipboardMessageProxy::new(input_events_tx.clone()), + clipboard::JsClipboardCallbacks { + on_remote_clipboard_changed: callback, + on_remote_received_format_list: remote_received_format_list_callback, + on_force_clipboard_update: force_clipboard_update_callback, + }, + ) + }); + let ws = WebSocket::open(&proxy_address).context("Couldn’t open WebSocket")?; // NOTE: ideally, when the WebSocket can’t be opened, the above call should fail with details on why is that @@ -247,7 +293,15 @@ impl SessionBuilder { let ws = WebSocketCompat::new(ws); - let (connection_result, ws) = connect(ws, config, auth_token, destination, pcb).await?; + let (connection_result, ws) = connect( + ws, + config, + auth_token, + destination, + pcb, + clipboard.as_ref().map(|clip| clip.backend()), + ) + .await?; info!("Connected!"); @@ -255,8 +309,6 @@ impl SessionBuilder { let (writer_tx, writer_rx) = mpsc::unbounded(); - let (input_events_tx, input_events_rx) = mpsc::unbounded(); - spawn_local(writer_task(writer_rx, rdp_writer)); Ok(Session { @@ -274,18 +326,26 @@ impl SessionBuilder { input_events_rx: RefCell::new(Some(input_events_rx)), rdp_reader: RefCell::new(Some(rdp_reader)), connection_result: RefCell::new(Some(connection_result)), + clipboard: RefCell::new(Some(clipboard)), }) } } -type FastPathInputEvents = smallvec::SmallVec<[FastPathInputEvent; 2]>; +pub(crate) type FastPathInputEvents = smallvec::SmallVec<[FastPathInputEvent; 2]>; + +#[derive(Debug)] +pub(crate) enum RdpInputEvent { + Cliprdr(ClipboardMessage), + ClipboardBackend(WasmClipboardBackendMessage), + FastPath(FastPathInputEvents), +} #[wasm_bindgen] pub struct Session { desktop_size: connector::DesktopSize, input_database: RefCell, writer_tx: mpsc::UnboundedSender>, - input_events_tx: mpsc::UnboundedSender, + input_events_tx: mpsc::UnboundedSender, render_canvas: HtmlCanvasElement, hide_pointer_callback: js_sys::Function, @@ -294,9 +354,10 @@ pub struct Session { show_pointer_callback_context: JsValue, // Consumed when `run` is called - input_events_rx: RefCell>>, + input_events_rx: RefCell>>, connection_result: RefCell>, rdp_reader: RefCell>>, + clipboard: RefCell>>, } #[wasm_bindgen] @@ -308,7 +369,7 @@ impl Session { .take() .context("RDP session can be started only once")?; - let mut fastpath_input_events = self + let mut input_events = self .input_events_rx .borrow_mut() .take() @@ -320,6 +381,8 @@ impl Session { .take() .expect("run called only once"); + let mut clipboard = self.clipboard.borrow_mut().take().expect("run called only once"); + let mut framed = ironrdp_futures::SingleThreadedFuturesFramed::new(rdp_reader); debug!("Initialize canvas"); @@ -351,10 +414,54 @@ impl Session { active_stage.process(&mut image, action, &payload)? } - input_events = fastpath_input_events.next() => { - let events = input_events.context("read next fastpath input events")?; + input_events = input_events.next() => { + let event = input_events.context("read next input events")?; - active_stage.process_fastpath_input(&mut image, &events).context("Fast path input events processing")? + match event { + RdpInputEvent::Cliprdr(message) => { + if let Some(cliprdr) = active_stage.get_svc_processor::() { + if let Some(svc_messages) = match message { + ClipboardMessage::SendInitiateCopy(formats) => Some( + cliprdr.initiate_copy(&formats) + .context("CLIPRDR initiate copy")? + ), + ClipboardMessage::SendFormatData(response) => Some( + cliprdr.submit_format_data(response) + .context("CLIPRDR submit format data")? + ), + ClipboardMessage::SendInitiatePaste(format) => Some( + cliprdr.initiate_paste(format) + .context("CLIPRDR initiate paste")? + ), + ClipboardMessage::Error(e) => { + error!("Clipboard backend error: {}", e); + None + } + } { + let frame = active_stage.process_svc_processor_messages(svc_messages)?; + // Send the messages to the server + vec![ActiveStageOutput::ResponseFrame(frame)] + } else { + // No messages to send to the server + Vec::new() + } + } else { + warn!("Clipboard event received, but Cliprdr is not available"); + Vec::new() + } + } + RdpInputEvent::ClipboardBackend(event) => { + if let Some(clipboard) = &mut clipboard { + clipboard.process_event(event)?; + } + // No RDP output frames for backend event processing + Vec::new() + } + RdpInputEvent::FastPath(events) => { + active_stage.process_fastpath_input(&mut image, &events) + .context("Fast path input events processing")? + } + } } }; @@ -417,7 +524,7 @@ impl Session { trace!("Inputs: {inputs:?}"); self.input_events_tx - .unbounded_send(inputs) + .unbounded_send(RdpInputEvent::FastPath(inputs)) .context("Send input events to writer task")?; } @@ -452,6 +559,16 @@ impl Session { // TODO: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/27915739-8f77-487e-9927-55008af7fd68 Ok(()) } + + pub async fn on_clipboard_paste(&self, content: ClipboardTransaction) -> Result<(), IronRdpError> { + self.input_events_tx + .unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::LocalClipboardChanged(content), + )) + .context("Send clipboard backend event")?; + + Ok(()) + } } fn build_config( @@ -522,6 +639,7 @@ async fn connect( proxy_auth_token: String, destination: String, pcb: Option, + clipboard_backend: Option, ) -> Result<(connector::ConnectionResult, WebSocketCompat), IronRdpError> { let mut framed = ironrdp_futures::SingleThreadedFuturesFramed::new(ws); @@ -530,6 +648,10 @@ async fn connect( .with_credssp_network_client(WasmNetworkClientFactory); // .with_static_channel(ironrdp::dvc::Drdynvc::new()); // FIXME: drdynvc is not working + if let Some(clipboard_backend) = clipboard_backend { + connector.attach_static_channel(Cliprdr::new(Box::new(clipboard_backend))); + } + let upgraded = connect_rdcleanpath(&mut framed, &mut connector, destination, proxy_auth_token, pcb).await?; let connection_result = ironrdp_futures::connect_finalize(upgraded, &mut framed, connector).await?; diff --git a/fuzz/fuzz_targets/cliprdr_format.rs b/fuzz/fuzz_targets/cliprdr_format.rs new file mode 100644 index 00000000..375f5495 --- /dev/null +++ b/fuzz/fuzz_targets/cliprdr_format.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::cliprdr_format(data); +}); diff --git a/web-client/iron-remote-gui/src/iron-remote-gui.svelte b/web-client/iron-remote-gui/src/iron-remote-gui.svelte index ef97f4d9..d826ab4f 100644 --- a/web-client/iron-remote-gui/src/iron-remote-gui.svelte +++ b/web-client/iron-remote-gui/src/iron-remote-gui.svelte @@ -9,13 +9,14 @@ import type { ResizeEvent } from './interfaces/ResizeEvent'; import { PublicAPI } from './services/PublicAPI'; import { ScreenScale } from './enums/ScreenScale'; + import { ClipboardContent, ClipboardTransaction } from '../../../crates/ironrdp-web/pkg/ironrdp_web'; export let scale = 'real'; export let verbose = 'false'; export let debugwasm: 'OFF' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE' = 'INFO'; export let flexcenter = 'true'; - let isVisible = false; + let isVisible: boolean = false; let capturingInputs = false; let currentComponent = get_current_component(); let canvas: HTMLCanvasElement; @@ -28,12 +29,365 @@ let wasmService = new WasmBridgeService(); let publicAPI = new PublicAPI(wasmService); + // Firefox's clipboard API is very limited, and doesn't support reading from the clipboard + // without changing browser settings via `about:config`. + // + // For firefox, we will use a different approach by marking `screen-wrapper` component + // as `contenteditable=true`, and then using the `onpaste`/`oncopy`/`oncut` events. + let isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; + + const CLIPBOARD_MONITORING_INTERVAL = 100; // ms + + let isClipboardApiSupported = false; + let lastClientClipboardItems = new Map(); + let lastClientClipboardTransaction: ClipboardTransaction | null = null; + let lastClipboardMonitorLoopError: Error | null = null; + + /* Firefox-specific BEGIN */ + + // See `ffRemoteClipboardTransaction` variable docs below + const FF_REMOTE_CLIPBOARD_TRANSACTION_SET_RETRY_INTERVAL = 100; // ms + const FF_REMOTE_CLIPBOARD_TRANSACTION_SET_MAX_RETRIES = 30; // 3 seconds (100ms * 30) + // On Firefox, this interval is used to stop delaying the keyboard events if the paste event has + // failed and we haven't received any clipboard data from the remote side. + const FF_LOCAL_CLIPBOARD_COPY_TIMEOUT = 1000; // 1s (For text-only data this should be enough) + + // In Firefox, we need this variable due to fact that `clipboard.writeText()` should only be + // called in scope of user-initiated event processing (e.g. keyboard event), but we receive + // clipboard data from the remote side asynchronously in wasm service callback. therefore we + // set this variable in callback and use its value on the user-initiated copy event. + let ffRemoteClipboardTransaction: ClipboardTransaction | null = null; + // For Firefox we need this variable to perform wait loop for the remote side to finish sending + // clipboard content to the client. + let ffRemoteClipboardTransactionRetriesLeft = 0; + let ffPostponeKeyboardEvents = false; + let ffDelayedKeyboardEvents: KeyboardEvent[] = []; + let ffCnavasFocused = false; + + /* Firefox-specific END */ + + /* Clipboard initialization BEGIN */ + + // Detect if browser supports async Clipboard API + if (!isFirefox && navigator.clipboard != undefined) { + if (navigator.clipboard.read != undefined && navigator.clipboard.write != undefined) { + isClipboardApiSupported = true; + } + } + + if (isFirefox) { + wasmService.setOnRemoteClipboardChanged(ffOnRemoteClipboardChanged); + wasmService.setOnRemoteReceivedFormatList(ffOnRemoteReceivedFormatList); + wasmService.setOnForceClipboardUpdate(onForceClipboardUpdate); + } else if (isClipboardApiSupported) { + wasmService.setOnRemoteClipboardChanged(onRemoteClipboardChanged); + wasmService.setOnForceClipboardUpdate(onForceClipboardUpdate); + + // Start the clipboard monitoring loop + setTimeout(onMonitorClipboard, CLIPBOARD_MONITORING_INTERVAL); + } + + /* Clipboard initialization END */ + + function isCopyKeyboardEvent(evt: KeyboardEvent) { + return ( + (evt.ctrlKey && evt.code === 'KeyC') || + (evt.ctrlKey && evt.code === 'KeyX') || + evt.code == 'Copy' || + evt.code == 'Cut' + ); + } + + function isPasteKeyboardEvent(evt: KeyboardEvent) { + return (evt.ctrlKey && evt.code === 'KeyV') || evt.code == 'Paste'; + } + + // This function is required to covert `ClipboardTransaction` to a object that can be used + // with `ClipboardItem` API. + function clipboardTransactionToRecord(transaction: ClipboardTransaction): Record { + let result = {} as Record; + + for (const item of transaction.content()) { + if (!(item instanceof ClipboardContent)) { + continue; + } + + let mime = item.mime_type(); + let value = new Blob([item.value()], { type: mime }); + result[mime] = value; + } + + return result; + } + + // This callback is required to send initial clipboard state if available. + function onForceClipboardUpdate() { + try { + if (lastClientClipboardTransaction) { + wasmService.onClipboardChanged(lastClientClipboardTransaction); + } else { + wasmService.onClipboardChanged(ClipboardTransaction.new()); + } + } catch (err) { + console.error('Failed to send initial clipboard state: ' + err); + } + } + + // This callback is required to update client clipboard state when remote side has changed. + function onRemoteClipboardChanged(transaction: ClipboardTransaction) { + try { + const mime_formats = clipboardTransactionToRecord(transaction); + const clipboard_item = new ClipboardItem(mime_formats); + navigator.clipboard.write([clipboard_item]); + } catch (err) { + console.error('Failed to set client clipboard: ' + err); + } + } + + // Called periodically to monitor clipboard changes + async function onMonitorClipboard() { + if (!document.hasFocus()) { + setTimeout(onMonitorClipboard, CLIPBOARD_MONITORING_INTERVAL); + return; + } + + try { + var value = await navigator.clipboard.read(); + + // Clipboard is empty + if (value.length == 0) { + return; + } + + // We only support one item at a time + var item = value[0]; + + if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) { + // Unsupported types + return; + } + + var values = new Map(); + var sameValue = true; + + // Saddly, browsers build new `ClipboardItem` object for each `read` call, + // so we can't do reference comparison here :( + // + // For monitoring loop approach we also can't drop this logic, as it will result in + // very frequent network activity. + for (const kind of item.types) { + // Get blob + const blobIsString = kind.startsWith('text/'); + + const blob = await item.getType(kind); + const value = blobIsString ? await blob.text() : new Uint8Array(await blob.arrayBuffer()); + + const is_equal = blobIsString + ? function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { + return a === b; + } + : function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { + if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) { + return false; + } + + return ( + a != undefined && b != undefined && a.length === b.length && a.every((v, i) => v === b[i]) + ); + }; + + const previousValue = lastClientClipboardItems.get(kind); + + if (!is_equal(previousValue, value)) { + // One of mime types has changed, we need to update the clipboard cache + sameValue = false; + } + + values.set(kind, value); + } + + // Clipboard has changed, we need to acknowledge remote side about it. + if (!sameValue) { + lastClientClipboardItems = values; + + let transaction = ClipboardTransaction.new(); + + // Iterate over `Record` type + values.forEach((value: string | Uint8Array, key: string) => { + // skip null/undefined values + if (value == null || value == undefined) { + return; + } + + if (key.startsWith('text/') && typeof value === 'string') { + transaction.add_content(ClipboardContent.new_text(key, value)); + } else if (key.startsWith('image/') && value instanceof Uint8Array) { + transaction.add_content(ClipboardContent.new_binary(key, value)); + } + }); + + if (!transaction.is_empty()) { + lastClientClipboardTransaction = transaction; + wasmService.onClipboardChanged(transaction); + } + } + } catch (err) { + if (err instanceof Error) { + const printError = + lastClipboardMonitorLoopError === null || + lastClipboardMonitorLoopError.toString() !== err.toString(); + // Prevent spamming the console with the same error + if (printError) { + console.error('Clipboard monitoring error: ' + err); + } + lastClipboardMonitorLoopError = err; + } + } finally { + setTimeout(onMonitorClipboard, CLIPBOARD_MONITORING_INTERVAL); + } + } + + /* Firefox-specific BEGIN */ + + function ffOnRemoteReceivedFormatList() { + try { + // We are ready to send delayed Ctrl+V events + ffSimulateDelayedKeyEvents(); + } catch (err) { + console.error('Failed to send delayed keyboard events: ' + err); + } + } + + // Only set variable on callback, the real clipboard update will be performed in keyboard + // callback. (User-initiated event is required for Firefox to allow clipboard write) + function ffOnRemoteClipboardChanged(transaction: ClipboardTransaction) { + ffRemoteClipboardTransaction = transaction; + } + + function ffWaitForRemoteClipboardTransactionSet() { + if (ffRemoteClipboardTransaction) { + try { + let transaction = ffRemoteClipboardTransaction; + ffRemoteClipboardTransaction = null; + for (const content of transaction.content()) { + // Firefox only supports text/plain mime type for clipboard writes :( + if (content.mime_type() === 'text/plain') { + navigator.clipboard.writeText(content.value()); + break; + } + } + } catch (err) { + console.error('Failed to set client clipboard: ' + err); + } + } else if (ffRemoteClipboardTransactionRetriesLeft > 0) { + ffRemoteClipboardTransactionRetriesLeft--; + setTimeout(ffWaitForRemoteClipboardTransactionSet, FF_REMOTE_CLIPBOARD_TRANSACTION_SET_RETRY_INTERVAL); + } + } + + function ffSimulateDelayedKeyEvents() { + if (ffDelayedKeyboardEvents.length > 0) { + for (const evt of ffDelayedKeyboardEvents) { + // simulate consecutive key events + keyboardEvent(evt); + } + ffDelayedKeyboardEvents = []; + } + ffPostponeKeyboardEvents = false; + } + + function ffOnPasteHandler(evt: ClipboardEvent) { + // We don't actually want to paste the clipboard data into the `contenteditable` div. + evt.preventDefault(); + + // `onpaste` events are handled only for Firefox, other browsers we use the clipboard API + // for reading the clipboard. + if (!isFirefox) { + // Prevent processing of the paste event by the browser. + return; + } + + try { + let transaction = ClipboardTransaction.new(); + + if (evt.clipboardData == null) { + return; + } + + for (var clipItem of evt.clipboardData.items) { + let mime = clipItem.type; + + if (mime.startsWith('text/')) { + clipItem.getAsString((str: string) => { + let content = ClipboardContent.new_text(mime, str); + transaction.add_content(content); + + if (!transaction.is_empty()) { + wasmService.onClipboardChanged(transaction); + } + }); + break; + } + + if (mime.startsWith('image/')) { + let file = clipItem.getAsFile(); + if (file == null) { + continue; + } + + file.arrayBuffer().then((buffer: ArrayBuffer) => { + const strict_buffer = new Uint8Array(buffer); + let content = ClipboardContent.new_binary(mime, strict_buffer); + transaction.add_content(content); + + if (!transaction.is_empty()) { + wasmService.onClipboardChanged(transaction); + } + }); + break; + } + } + } catch (err) { + console.error('Failed to update remote clipboard: ' + err); + } + } + + /* Firefox-specific END */ + function initListeners() { serverBridgeListeners(); userInteractionListeners(); - window.addEventListener('keydown', keyboardEvent, false); - window.addEventListener('keyup', keyboardEvent, false); + function captureKeys(evt: KeyboardEvent) { + if (capturingInputs) { + if (ffPostponeKeyboardEvents) { + evt.preventDefault(); + ffDelayedKeyboardEvents.push(evt); + return; + } + + // For Firefox we need to make `onpaste` event still fire even if + // keyboard is being captured. Not capturing `Ctrl + V` should not create any + // side effects, therefore is safe to skip capture for it. + let isFirefoxPaste = isFirefox && isPasteKeyboardEvent(evt); + + if (isFirefoxPaste) { + ffPostponeKeyboardEvents = true; + ffDelayedKeyboardEvents = []; + ffDelayedKeyboardEvents.push(evt); + + // If during the given timeout we weren't able to finish the copy sequence, we need to + // simulate all queued keyboard events. + setTimeout(ffSimulateDelayedKeyEvents, FF_LOCAL_CLIPBOARD_COPY_TIMEOUT); + return; + } + + keyboardEvent(evt); + } + } + + window.addEventListener('keydown', captureKeys, false); + window.addEventListener('keyup', captureKeys, false); } function resetHostStyle() { @@ -208,7 +562,27 @@ } function setMouseButtonState(state: MouseEvent, isDown: boolean) { - wasmService.mouseButtonState(state, isDown); + if (isFirefox) { + let get_canvas_parent = () => { + return currentComponent.shadowRoot.getElementById('renderer').parentElement; + }; + + if (isDown && state.button == 0 && !ffCnavasFocused) { + // Do not capture first mouse down event on Firefox, as we need to transfer focus to the + // canvas first in order to receive paste events. + // wasmService.mouseButtonState(state, isDown, false); + // Focus `contenteditable` element to receive `on_paste` events + get_canvas_parent().focus(); + // Finish the focus sequence on Firefox + ffCnavasFocused = true; + } else { + // This is needed to prevent visible "double click" selection on + // `texteditable` element + get_canvas_parent().blur(); + } + } + + wasmService.mouseButtonState(state, isDown, true); } function mouseWheel(evt: WheelEvent) { @@ -226,7 +600,22 @@ } function keyboardEvent(evt: KeyboardEvent) { + const browserHasClipboardAccess = + navigator.clipboard != undefined && navigator.clipboard.writeText != undefined; + + if (isFirefox && browserHasClipboardAccess && isCopyKeyboardEvent(evt)) { + // Special processing for firefox, as the only way Firefox supports clipboard write is + // only after some user-initiated event (e.g. keyboard event). + // therefore we need to wait here for the clipboard data to be ready. + + ffRemoteClipboardTransactionRetriesLeft = FF_REMOTE_CLIPBOARD_TRANSACTION_SET_MAX_RETRIES; + ffWaitForRemoteClipboardTransactionSet(); + } + wasmService.sendKeyboardEvent(evt); + + // Propagate further + return true; } function getWindowSize() { @@ -275,7 +664,7 @@ class:capturing-inputs={capturingInputs} style={wrapperStyle} > -
+
setMouseButtonState(event, true)} diff --git a/web-client/iron-remote-gui/src/services/wasm-bridge.service.ts b/web-client/iron-remote-gui/src/services/wasm-bridge.service.ts index 6d78f8f0..9300b34c 100644 --- a/web-client/iron-remote-gui/src/services/wasm-bridge.service.ts +++ b/web-client/iron-remote-gui/src/services/wasm-bridge.service.ts @@ -7,6 +7,7 @@ import init, { IronRdpError, Session, SessionBuilder, + ClipboardTransaction, } from '../../../../crates/ironrdp-web/pkg/ironrdp_web'; import { loggingService } from './logging.service'; import { catchError, filter, map } from 'rxjs/operators'; @@ -24,6 +25,10 @@ import type { MousePosition } from '../interfaces/MousePosition'; import type { SessionEvent } from '../interfaces/session-event'; import type { DesktopSize as IDesktopSize } from '../interfaces/DesktopSize'; +type OnRemoteClipboardChanged = (transaction: ClipboardTransaction) => void; +type OnRemoteReceivedFormatsList = () => void; +type OnForceClipboardUpdate = () => void; + export class WasmBridgeService { private _resize: Subject = new Subject(); private mousePosition: BehaviorSubject = new BehaviorSubject({ @@ -35,6 +40,9 @@ export class WasmBridgeService { private scale: BehaviorSubject = new BehaviorSubject(ScreenScale.Fit as ScreenScale); private canvas?: HTMLCanvasElement; private keyboardActive: boolean = false; + private onRemoteClipboardChanged?: OnRemoteClipboardChanged; + private onRemoteReceivedFormatList?: OnRemoteReceivedFormatsList; + private onForceClipboardUpdate?: OnForceClipboardUpdate; resize: Observable; session?: Session; @@ -56,6 +64,22 @@ export class WasmBridgeService { ironrdp_init(LogType[debug]); } + /// Callback to set the local clipboard content to data received from the remote. + setOnRemoteClipboardChanged(callback: OnRemoteClipboardChanged) { + this.onRemoteClipboardChanged = callback; + } + + /// Callback which is called when the remote sends a list of supported clipboard formats. + setOnRemoteReceivedFormatList(callback: OnRemoteReceivedFormatsList) { + this.onRemoteReceivedFormatList = callback; + } + + /// Callback which is called when the remote requests a forced clipboard update (e.g. on + /// clipboard initialization sequence) + setOnForceClipboardUpdate(callback: OnForceClipboardUpdate) { + this.onForceClipboardUpdate = callback; + } + mouseIn(event: MouseEvent) { this.syncModifier(event); this.keyboardActive = true; @@ -76,8 +100,10 @@ export class WasmBridgeService { this.session?.shutdown(); } - mouseButtonState(event: MouseEvent, isDown: boolean) { - event.preventDefault(); // prevent default behavior (context menu, etc) + mouseButtonState(event: MouseEvent, isDown: boolean, preventDefault: boolean) { + if (preventDefault) { + event.preventDefault(); // prevent default behavior (context menu, etc) + } const mouseFnc = isDown ? DeviceEvent.new_mouse_button_pressed : DeviceEvent.new_mouse_button_released; this.doTransactionFromDeviceEvents([mouseFnc(event.button)]); } @@ -116,6 +142,15 @@ export class WasmBridgeService { if (preConnectionBlob != null) { sessionBuilder.pcb(preConnectionBlob); } + if (this.onRemoteClipboardChanged != null) { + sessionBuilder.remote_clipboard_changed_callback(this.onRemoteClipboardChanged); + } + if (this.onRemoteReceivedFormatList != null) { + sessionBuilder.remote_received_format_list_callback(this.onRemoteReceivedFormatList); + } + if (this.onForceClipboardUpdate != null) { + sessionBuilder.force_clipboard_update_callback(this.onForceClipboardUpdate); + } if (desktopSize != null) { sessionBuilder.desktop_size(DesktopSize.new(desktopSize.width, desktopSize.height)); @@ -203,6 +238,15 @@ export class WasmBridgeService { this.canvas = canvas; } + /// Triggered by the browser when local clipboard is updated. Clipboard backend should + /// cache the content and send it to the server when it is requested. + onClipboardChanged(transaction: ClipboardTransaction): Promise { + const onClipboardChangedPromise = async () => { + await this.session?.on_clipboard_paste(transaction); + }; + return onClipboardChangedPromise(); + } + private releaseAllInputs() { this.session?.release_all_inputs(); }