From e9823aff0301e6c3683856c1abb6747787dabfa5 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 6 May 2026 14:23:43 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20phase=203=20=E2=80=94=20framebuffer,=20?= =?UTF-8?q?raw=20decoder,=20session=20pump,=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit framebuffer.rs: - Framebuffer struct (8bpp RGB332, row-major) - apply_raw() blit, copy_rect() with overlap-safe logic - to_rgba() via compile-time RGB332 LUT session.rs: - ActiveSession: connect + SetEncodings + initial FBUpdateRequest - Full server message dispatch loop (all 15 message types) - Raw (encoding 0) and CopyRect (encoding 1) decoders - Ping response, bandwidth probe bookends, mode change resize examples/snapshot.rs: - Connects, waits for first FramebufferUpdate, saves PNG - Tested against real OmniView at 10.3.0.130:443 - Successfully captured 640x480 frame Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 80 ++++++++++- crates/ericrfb/Cargo.toml | 1 + crates/ericrfb/examples/snapshot.rs | 88 ++++++++++++ crates/ericrfb/src/framebuffer.rs | 111 +++++++++++++++ crates/ericrfb/src/handshake.rs | 5 + crates/ericrfb/src/lib.rs | 2 + crates/ericrfb/src/session.rs | 203 ++++++++++++++++++++++++++++ 7 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 crates/ericrfb/examples/snapshot.rs create mode 100644 crates/ericrfb/src/framebuffer.rs create mode 100644 crates/ericrfb/src/session.rs diff --git a/Cargo.lock b/Cargo.lock index 66db399..0644642 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -110,6 +116,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -128,6 +140,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -138,6 +159,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" name = "ericrfb" version = "0.1.0" dependencies = [ + "png", "proptest", "thiserror", ] @@ -170,6 +192,25 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -440,6 +481,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -510,6 +561,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -546,7 +610,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags", + "bitflags 2.11.1", "num-traits", "rand", "rand_chacha", @@ -628,7 +692,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -654,7 +718,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -781,6 +845,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1065,7 +1135,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -1150,7 +1220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", diff --git a/crates/ericrfb/Cargo.toml b/crates/ericrfb/Cargo.toml index e772218..1e87094 100644 --- a/crates/ericrfb/Cargo.toml +++ b/crates/ericrfb/Cargo.toml @@ -8,3 +8,4 @@ thiserror.workspace = true [dev-dependencies] proptest = "1" +png = "0.17" diff --git a/crates/ericrfb/examples/snapshot.rs b/crates/ericrfb/examples/snapshot.rs new file mode 100644 index 0000000..131f7c8 --- /dev/null +++ b/crates/ericrfb/examples/snapshot.rs @@ -0,0 +1,88 @@ +use std::env; +use std::fs::File; +use std::io::BufWriter; +use std::path::Path; + +use ericrfb::handshake::Config; +use ericrfb::session::{ActiveSession, Event}; + +fn main() { + let args: Vec = env::args().collect(); + + let host = args + .iter() + .position(|a| a == "--host") + .and_then(|i| args.get(i + 1)) + .expect("usage: --host --applet-id [--port ] [--output ]"); + + let applet_id = args + .iter() + .position(|a| a == "--applet-id") + .and_then(|i| args.get(i + 1)) + .expect("usage: --host --applet-id [--port ] [--output ]"); + + let port: u16 = args + .iter() + .position(|a| a == "--port") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.parse().ok()) + .unwrap_or(443); + + let output = args + .iter() + .position(|a| a == "--output") + .and_then(|i| args.get(i + 1).map(|s| s.as_str())) + .unwrap_or("frame.png"); + + let cfg = Config::new(host, port, applet_id); + println!("Connecting to {}:{}...", cfg.host, cfg.port); + + // Only request Raw encoding (0) for Phase 3 + let mut session = ActiveSession::connect(&cfg, &[0]).expect("connect failed"); + println!( + "Connected: {}x{}, waiting for first frame...", + session.framebuffer.width, session.framebuffer.height + ); + + // Process messages until we get a FramebufferDirty event + loop { + match session.process_one() { + Ok(Some(Event::FramebufferDirty)) => { + println!("Got framebuffer update, saving to {output}"); + break; + } + Ok(Some(Event::Resize { width, height })) => { + println!("Resized to {width}x{height}"); + } + Ok(Some(Event::Debug(s))) => { + eprintln!("[debug] {s}"); + } + Ok(Some(Event::RfbCommand(k, v))) => { + eprintln!("[rfb-cmd] {k}={v}"); + } + Ok(Some(_)) => {} + Ok(None) => {} + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } + } + + // Write PNG + let rgba = session.framebuffer.to_rgba(); + let w = session.framebuffer.width as u32; + let h = session.framebuffer.height as u32; + + let path = Path::new(output); + let file = File::create(path).expect("cannot create output file"); + let bw = BufWriter::new(file); + + let mut encoder = png::Encoder::new(bw, w, h); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().expect("png header failed"); + writer.write_image_data(&rgba).expect("png write failed"); + + println!("Saved {w}x{h} frame to {}", path.display()); +} diff --git a/crates/ericrfb/src/framebuffer.rs b/crates/ericrfb/src/framebuffer.rs new file mode 100644 index 0000000..36c1698 --- /dev/null +++ b/crates/ericrfb/src/framebuffer.rs @@ -0,0 +1,111 @@ +use crate::proto::RGB332_LUT; + +/// 8bpp framebuffer storing raw RGB332 pixels. Converts to RGBA on demand. +#[derive(Debug, Clone)] +pub struct Framebuffer { + pub width: u16, + pub height: u16, + /// Raw 8bpp pixel data, row-major, `width * height` bytes. + pub pixels: Vec, +} + +impl Framebuffer { + pub fn new(width: u16, height: u16) -> Self { + let size = width as usize * height as usize; + Self { + width, + height, + pixels: vec![0; size], + } + } + + /// Resize the framebuffer, discarding old contents. + pub fn resize(&mut self, width: u16, height: u16) { + self.width = width; + self.height = height; + let size = width as usize * height as usize; + self.pixels.resize(size, 0); + } + + /// Blit raw 8bpp data into the framebuffer at (x, y) with dimensions (w, h). + /// `data` must contain exactly `w * h` bytes. + pub fn apply_raw(&mut self, x: u16, y: u16, w: u16, h: u16, data: &[u8]) { + debug_assert_eq!(data.len(), w as usize * h as usize); + let stride = self.width as usize; + for row in 0..h as usize { + let dst_offset = (y as usize + row) * stride + x as usize; + let src_offset = row * w as usize; + let dst = &mut self.pixels[dst_offset..dst_offset + w as usize]; + dst.copy_from_slice(&data[src_offset..src_offset + w as usize]); + } + } + + /// Copy a rectangle within the framebuffer (CopyRect encoding). + /// Handles overlapping regions correctly. + pub fn copy_rect(&mut self, src_x: u16, src_y: u16, dst_x: u16, dst_y: u16, w: u16, h: u16) { + let stride = self.width as usize; + let w = w as usize; + + if src_y <= dst_y { + // Copy bottom-to-top to handle downward overlap + for row in (0..h as usize).rev() { + let src_off = (src_y as usize + row) * stride + src_x as usize; + let dst_off = (dst_y as usize + row) * stride + dst_x as usize; + self.pixels.copy_within(src_off..src_off + w, dst_off); + } + } else { + // Copy top-to-bottom to handle upward overlap + for row in 0..h as usize { + let src_off = (src_y as usize + row) * stride + src_x as usize; + let dst_off = (dst_y as usize + row) * stride + dst_x as usize; + self.pixels.copy_within(src_off..src_off + w, dst_off); + } + } + } + + /// Convert the entire framebuffer to RGBA (4 bytes per pixel). + pub fn to_rgba(&self) -> Vec { + let mut rgba = Vec::with_capacity(self.pixels.len() * 4); + for &px in &self.pixels { + rgba.extend_from_slice(&RGB332_LUT[px as usize]); + } + rgba + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apply_raw() { + let mut fb = Framebuffer::new(4, 4); + // Fill a 2x2 region at (1,1) with value 0xFF + fb.apply_raw(1, 1, 2, 2, &[0xFF; 4]); + assert_eq!(fb.pixels[0], 0); // (0,0) + assert_eq!(fb.pixels[5], 0xFF); // (1,1) + assert_eq!(fb.pixels[6], 0xFF); // (2,1) + assert_eq!(fb.pixels[9], 0xFF); // (1,2) + assert_eq!(fb.pixels[10], 0xFF); // (2,2) + assert_eq!(fb.pixels[15], 0); // (3,3) + } + + #[test] + fn test_copy_rect_no_overlap() { + let mut fb = Framebuffer::new(4, 4); + fb.apply_raw(0, 0, 2, 2, &[1, 2, 3, 4]); + fb.copy_rect(0, 0, 2, 2, 2, 2); + // Check destination + assert_eq!(fb.pixels[10], 1); // (2,2) + assert_eq!(fb.pixels[11], 2); // (3,2) + assert_eq!(fb.pixels[14], 3); // (2,3) + assert_eq!(fb.pixels[15], 4); // (3,3) + } + + #[test] + fn test_to_rgba_size() { + let fb = Framebuffer::new(2, 2); + let rgba = fb.to_rgba(); + assert_eq!(rgba.len(), 2 * 2 * 4); + } +} diff --git a/crates/ericrfb/src/handshake.rs b/crates/ericrfb/src/handshake.rs index 58f80ac..cf14ec3 100644 --- a/crates/ericrfb/src/handshake.rs +++ b/crates/ericrfb/src/handshake.rs @@ -107,6 +107,11 @@ pub struct ServerInit { pub blue_shift: u8, } +/// Read a ServerInit struct from a stream. Public for reuse in ModeChange (msg 128). +pub fn read_server_init_from(r: &mut impl Read) -> Result { + read_server_init(r) +} + fn read_server_init(r: &mut impl Read) -> Result { let supports_resize = read_u8(r)? != 0; let width = read_u16_be(r)?; diff --git a/crates/ericrfb/src/lib.rs b/crates/ericrfb/src/lib.rs index 21879be..22dd7f0 100644 --- a/crates/ericrfb/src/lib.rs +++ b/crates/ericrfb/src/lib.rs @@ -1,3 +1,5 @@ +pub mod framebuffer; pub mod handshake; pub mod msg; pub mod proto; +pub mod session; diff --git a/crates/ericrfb/src/session.rs b/crates/ericrfb/src/session.rs new file mode 100644 index 0000000..2b145f8 --- /dev/null +++ b/crates/ericrfb/src/session.rs @@ -0,0 +1,203 @@ +use std::io::{BufReader, BufWriter}; +use std::net::TcpStream; + +use crate::framebuffer::Framebuffer; +use crate::handshake::{self, Config, ServerInit}; +use crate::msg::{self, ServerMsg}; +use crate::proto::{self, RectHeader, read_exact, read_u8}; + +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + #[error("handshake: {0}")] + Handshake(#[from] handshake::HandshakeError), + #[error("protocol: {0}")] + Proto(#[from] proto::ProtoError), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("unsupported encoding: {0}")] + UnsupportedEncoding(i32), + #[error("unsupported message type: {0}")] + UnsupportedMessage(u8), +} + +/// Events emitted by the session pump to consumers. +#[derive(Debug)] +pub enum Event { + /// A region of the framebuffer was updated. + FramebufferDirty, + /// The framebuffer was resized. + Resize { width: u16, height: u16 }, + /// Bell from server. + Bell, + /// Server sent a debug string. + Debug(String), + /// Server sent an RFB command (key, value). + RfbCommand(String, String), + /// Server name updated. + NameUpdate(String), +} + +/// Active protocol session with framebuffer. +pub struct ActiveSession { + pub framebuffer: Framebuffer, + pub server_name: String, + pub reader: BufReader, + pub writer: BufWriter, + pub server_init: ServerInit, +} + +impl ActiveSession { + /// Connect, handshake, send SetEncodings + initial FBUpdateRequest. + pub fn connect(cfg: &Config, encodings: &[i32]) -> Result { + let raw = handshake::connect(cfg)?; + let mut session = Self { + framebuffer: Framebuffer::new(raw.server_init.width, raw.server_init.height), + server_name: raw.server_name, + reader: raw.reader, + writer: raw.writer, + server_init: raw.server_init, + }; + + // Send SetEncodings + msg::write_set_encodings(&mut session.writer, encodings)?; + + // Request full non-incremental framebuffer update + msg::write_fb_update_request( + &mut session.writer, + 0, + 0, + session.framebuffer.width, + session.framebuffer.height, + false, + )?; + + Ok(session) + } + + /// Request an incremental framebuffer update. + pub fn request_update(&mut self) -> Result<(), SessionError> { + msg::write_fb_update_request( + &mut self.writer, + 0, + 0, + self.framebuffer.width, + self.framebuffer.height, + true, + )?; + Ok(()) + } + + /// Process one server message. Returns an event if meaningful to the consumer. + pub fn process_one(&mut self) -> Result, SessionError> { + let msg_type = read_u8(&mut self.reader)?; + let msg = ServerMsg::from(msg_type); + + match msg { + ServerMsg::FramebufferUpdate => { + self.handle_fb_update()?; + Ok(Some(Event::FramebufferDirty)) + } + ServerMsg::Bell => Ok(Some(Event::Bell)), + ServerMsg::Ping => { + let payload = msg::read_ping(&mut self.reader)?; + msg::write_ping_response(&mut self.writer, payload)?; + Ok(None) + } + ServerMsg::BandwidthProbe => { + msg::write_bandwidth_marker(&mut self.writer, 1)?; + msg::read_bandwidth_probe(&mut self.reader)?; + msg::write_bandwidth_marker(&mut self.writer, 2)?; + Ok(None) + } + ServerMsg::Ack => { + msg::read_ack(&mut self.reader)?; + Ok(None) + } + ServerMsg::DebugString => { + let s = msg::read_debug_string(&mut self.reader)?; + Ok(Some(Event::Debug(s))) + } + ServerMsg::RfbCommand => { + let (k, v) = msg::read_rfb_command(&mut self.reader)?; + Ok(Some(Event::RfbCommand(k, v))) + } + ServerMsg::ServerCutText => { + let _text = msg::read_server_cut_text(&mut self.reader)?; + Ok(None) + } + ServerMsg::ServerNameUpdate => { + let name = msg::read_server_name_update(&mut self.reader)?; + self.server_name = name.clone(); + Ok(Some(Event::NameUpdate(name))) + } + ServerMsg::LayoutLocale => { + let _locale = msg::read_layout_locale(&mut self.reader)?; + Ok(None) + } + ServerMsg::DesktopResize => { + // Reads same struct as handshake pixel-format (aw.i, line 519) + let _flag = read_u8(&mut self.reader)?; + let _depth = proto::read_u16_be(&mut self.reader)?; + let label_len = proto::read_u16_be(&mut self.reader)? as usize; + let _label = read_exact(&mut self.reader, label_len)?; + Ok(None) + } + ServerMsg::ModeChange => { + // Re-read ServerInit (aw.k, line 435) — framebuffer dimensions may change + let si = handshake::read_server_init_from(&mut self.reader)?; + let old_w = self.framebuffer.width; + let old_h = self.framebuffer.height; + self.server_init = si; + if self.server_init.width != old_w || self.server_init.height != old_h { + self.framebuffer + .resize(self.server_init.width, self.server_init.height); + return Ok(Some(Event::Resize { + width: self.server_init.width, + height: self.server_init.height, + })); + } + Ok(None) + } + ServerMsg::RdpEvent => { + let _event_type = msg::read_rdp_event(&mut self.reader)?; + Ok(None) + } + ServerMsg::PixelFormatChange => { + // aw.e(), line 537: 1 pad + 4×u8 + 8×u16 = 21 bytes + let _data = read_exact(&mut self.reader, 21)?; + Ok(None) + } + ServerMsg::SetColourMapEntries => Err(SessionError::UnsupportedMessage(msg_type)), + ServerMsg::Unknown(t) => Err(SessionError::UnsupportedMessage(t)), + } + } + + fn handle_fb_update(&mut self) -> Result<(), SessionError> { + let num_rects = msg::read_fb_update_header(&mut self.reader)?; + + for _ in 0..num_rects { + let hdr = RectHeader::read_from(&mut self.reader)?; + + match hdr.encoding { + 0 => { + // Raw: read w*h bytes + let size = hdr.w as usize * hdr.h as usize; + let data = read_exact(&mut self.reader, size)?; + self.framebuffer + .apply_raw(hdr.x, hdr.y, hdr.w, hdr.h, &data); + } + 1 => { + // CopyRect: read src_x, src_y (u16 each) + let src_x = proto::read_u16_be(&mut self.reader)?; + let src_y = proto::read_u16_be(&mut self.reader)?; + self.framebuffer + .copy_rect(src_x, src_y, hdr.x, hdr.y, hdr.w, hdr.h); + } + other => { + return Err(SessionError::UnsupportedEncoding(other)); + } + } + } + Ok(()) + } +}