diff --git a/src-tauri/src/commands/rdp_commands.rs b/src-tauri/src/commands/rdp_commands.rs index 8b42c74..808cfbc 100644 --- a/src-tauri/src/commands/rdp_commands.rs +++ b/src-tauri/src/commands/rdp_commands.rs @@ -19,18 +19,37 @@ pub fn connect_rdp( state.rdp.connect(config, app_handle) } -/// Get the current frame buffer as raw RGBA bytes via binary IPC. +/// Get the dirty region since last call as raw RGBA bytes via binary IPC. /// -/// Uses `tauri::ipc::Response` to return raw bytes without JSON serialization. -/// Pixel format: RGBA, 4 bytes per pixel, row-major, top-left origin. -/// Returns empty payload if frame hasn't changed since last call. +/// Binary format: 8-byte header + pixel data +/// Header: [x: u16, y: u16, width: u16, height: u16] (little-endian) +/// If header is all zeros, the payload is a full frame (width*height*4 bytes). +/// If header is non-zero, payload contains only the dirty rectangle pixels. +/// Returns empty payload if nothing changed. #[tauri::command] pub fn rdp_get_frame( session_id: String, state: State<'_, AppState>, ) -> Result { - let frame = state.rdp.get_frame(&session_id)?; - Ok(Response::new(frame)) + let (region, pixels) = state.rdp.get_frame(&session_id)?; + if pixels.is_empty() { + return Ok(Response::new(Vec::new())); + } + // Prepend 8-byte dirty rect header + let mut out = Vec::with_capacity(8 + pixels.len()); + match region { + Some(rect) => { + out.extend_from_slice(&rect.x.to_le_bytes()); + out.extend_from_slice(&rect.y.to_le_bytes()); + out.extend_from_slice(&rect.width.to_le_bytes()); + out.extend_from_slice(&rect.height.to_le_bytes()); + } + None => { + out.extend_from_slice(&[0u8; 8]); // full frame marker + } + } + out.extend_from_slice(&pixels); + Ok(Response::new(out)) } /// Send a mouse event to an RDP session. diff --git a/src-tauri/src/rdp/mod.rs b/src-tauri/src/rdp/mod.rs index a75a1ab..c972cf6 100644 --- a/src-tauri/src/rdp/mod.rs +++ b/src-tauri/src/rdp/mod.rs @@ -66,14 +66,25 @@ enum InputEvent { Disconnect, } +/// Dirty rectangle from the last GraphicsUpdate — used for partial frame transfer. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DirtyRect { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + struct RdpSessionHandle { id: String, hostname: String, width: u16, height: u16, /// Frame buffer: RDP thread writes via RwLock write, IPC reads via RwLock read. - /// Brief write-lock per GraphicsUpdate, concurrent reads for get_frame. front_buffer: Arc>>, + /// Accumulated dirty region since last get_frame — union of all GraphicsUpdate rects. + dirty_region: Arc>>, frame_dirty: Arc, input_tx: mpsc::UnboundedSender, } @@ -102,6 +113,7 @@ impl RdpService { pixel[3] = 255; } let front_buffer = Arc::new(std::sync::RwLock::new(initial_buf)); + let dirty_region = Arc::new(std::sync::Mutex::new(None)); let frame_dirty = Arc::new(AtomicBool::new(false)); let (input_tx, input_rx) = mpsc::unbounded_channel(); @@ -112,6 +124,7 @@ impl RdpService { width, height, front_buffer: front_buffer.clone(), + dirty_region: dirty_region.clone(), frame_dirty: frame_dirty.clone(), input_tx, }); @@ -159,6 +172,7 @@ impl RdpService { connection_result, framed, front_buffer, + dirty_region, frame_dirty, input_rx, width as u16, @@ -202,13 +216,43 @@ impl RdpService { Ok(session_id) } - pub fn get_frame(&self, session_id: &str) -> Result, String> { + /// Get the dirty region since the last call. Returns (region_metadata, pixel_bytes). + /// The pixel bytes contain only the dirty rectangle in row-major RGBA order. + /// If nothing changed, returns empty bytes. If the dirty region covers >50% of the + /// frame, falls back to full frame for efficiency (avoids row-by-row extraction). + pub fn get_frame(&self, session_id: &str) -> Result<(Option, Vec), String> { let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?; if !handle.frame_dirty.swap(false, Ordering::Acquire) { - return Ok(Vec::new()); // No change — return empty + return Ok((None, Vec::new())); } + + let region = handle.dirty_region.lock().unwrap_or_else(|e| e.into_inner()).take(); let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner()); - Ok(buf.clone()) + let stride = handle.width as usize * 4; + let total_pixels = handle.width as usize * handle.height as usize; + + match region { + Some(rect) if (rect.width as usize * rect.height as usize) < total_pixels / 2 => { + // Partial: extract only the dirty rectangle + let rw = rect.width as usize; + let rh = rect.height as usize; + let rx = rect.x as usize; + let ry = rect.y as usize; + let mut out = Vec::with_capacity(rw * rh * 4); + for row in ry..ry + rh { + let start = row * stride + rx * 4; + let end = start + rw * 4; + if end <= buf.len() { + out.extend_from_slice(&buf[start..end]); + } + } + Ok((Some(rect), out)) + } + _ => { + // Full frame: dirty region covers most of the screen or is missing + Ok((None, buf.clone())) + } + } } pub fn get_frame_raw(&self, session_id: &str) -> Result, String> { @@ -342,7 +386,7 @@ async fn establish_connection(config: connector::Config, hostname: &str, port: u Ok((connection_result, upgraded_framed)) } -async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, front_buffer: Arc>>, frame_dirty: Arc, mut input_rx: mpsc::UnboundedReceiver, width: u16, height: u16, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> { +async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, front_buffer: Arc>>, dirty_region: Arc>>, frame_dirty: Arc, mut input_rx: mpsc::UnboundedReceiver, width: u16, height: u16, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> { let (mut reader, mut writer) = split_tokio_framed(framed); let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height); let mut active_stage = ActiveStage::new(connection_result); @@ -400,16 +444,43 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade for out in outputs { match out { ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; } - ActiveStageOutput::GraphicsUpdate(_region) => { - // Write decoded image directly to front buffer. - // Single RwLock write — readers use read lock, no contention. + ActiveStageOutput::GraphicsUpdate(region) => { + let rx = region.left as usize; + let ry = region.top as usize; + let rr = (region.right as usize).saturating_add(1).min(width as usize); + let rb = (region.bottom as usize).saturating_add(1).min(height as usize); + let stride = width as usize * 4; + + // Copy only the dirty rectangle rows from decoded image → front buffer { let src = image.data(); let mut front = front_buffer.write().unwrap_or_else(|e| e.into_inner()); - if src.len() == front.len() { front.copy_from_slice(src); } else { *front = src.to_vec(); } + for row in ry..rb { + let src_start = row * stride + rx * 4; + let src_end = row * stride + rr * 4; + if src_end <= src.len() && src_end <= front.len() { + front[src_start..src_end].copy_from_slice(&src[src_start..src_end]); + } + } } + + // Accumulate dirty region (union of all rects since last get_frame) + { + let new_rect = DirtyRect { x: rx as u16, y: ry as u16, width: (rr - rx) as u16, height: (rb - ry) as u16 }; + let mut dr = dirty_region.lock().unwrap_or_else(|e| e.into_inner()); + *dr = Some(match dr.take() { + None => new_rect, + Some(prev) => { + let x = prev.x.min(new_rect.x); + let y = prev.y.min(new_rect.y); + let r = (prev.x + prev.width).max(new_rect.x + new_rect.width); + let b = (prev.y + prev.height).max(new_rect.y + new_rect.height); + DirtyRect { x, y, width: r - x, height: b - y } + } + }); + } + frame_dirty.store(true, Ordering::Release); - // Signal frontend — rAF coalescing prevents flood let _ = app_handle.emit(&format!("rdp:frame:{}", session_id), ()); } ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); } diff --git a/src/components/common/StatusBar.vue b/src/components/common/StatusBar.vue index 476296f..8e37aa2 100644 --- a/src/components/common/StatusBar.vue +++ b/src/components/common/StatusBar.vue @@ -1,5 +1,5 @@