feat: phase 3 — framebuffer, raw decoder, session pump, snapshot
All checks were successful
CI / fmt (push) Successful in 37s
CI / check (push) Successful in 1m0s
CI / clippy (push) Successful in 1m4s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 14:23:43 +03:00
parent 1bd43fc1f9
commit e9823aff03
7 changed files with 485 additions and 5 deletions

View File

@@ -8,3 +8,4 @@ thiserror.workspace = true
[dev-dependencies]
proptest = "1"
png = "0.17"

View File

@@ -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<String> = env::args().collect();
let host = args
.iter()
.position(|a| a == "--host")
.and_then(|i| args.get(i + 1))
.expect("usage: --host <ip> --applet-id <token> [--port <port>] [--output <file.png>]");
let applet_id = args
.iter()
.position(|a| a == "--applet-id")
.and_then(|i| args.get(i + 1))
.expect("usage: --host <ip> --applet-id <token> [--port <port>] [--output <file.png>]");
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());
}

View File

@@ -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<u8>,
}
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<u8> {
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);
}
}

View File

@@ -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<ServerInit, HandshakeError> {
read_server_init(r)
}
fn read_server_init(r: &mut impl Read) -> Result<ServerInit, HandshakeError> {
let supports_resize = read_u8(r)? != 0;
let width = read_u16_be(r)?;

View File

@@ -1,3 +1,5 @@
pub mod framebuffer;
pub mod handshake;
pub mod msg;
pub mod proto;
pub mod session;

View File

@@ -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<TcpStream>,
pub writer: BufWriter<TcpStream>,
pub server_init: ServerInit,
}
impl ActiveSession {
/// Connect, handshake, send SetEncodings + initial FBUpdateRequest.
pub fn connect(cfg: &Config, encodings: &[i32]) -> Result<Self, SessionError> {
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<Option<Event>, 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(())
}
}