diff --git a/electron/dist/main.js b/electron/dist/main.js index 2dfe426..d7601c4 100644 --- a/electron/dist/main.js +++ b/electron/dist/main.js @@ -45,7 +45,8 @@ var MAGIC = { FORWARD_POINTER: 1464815685, FORWARD_KEYBOARD: 1464814405, FORWARD_RELATIVE_POINTER: 1464816197, - POINTER_LOCK_REQUEST: 1464815692 + POINTER_LOCK_REQUEST: 1464815692, + ENV_UPDATE: 1464812885 }; function stringToBuffer(str) { return Buffer.from(str, "utf-8"); @@ -1143,8 +1144,8 @@ function connectToCompositor() { if (compositorRxBuffer.length < 8) break; const nameLen = compositorRxBuffer.readUInt32LE(4); - const headerSize = 8 + nameLen + 20; - if (compositorRxBuffer.length < headerSize) + const baseHeader = 8 + nameLen + 20 + 4; + if (compositorRxBuffer.length < baseHeader) break; let off = 8; const windowName = compositorRxBuffer.slice(off, off + nameLen).toString("utf8"); @@ -1158,29 +1159,68 @@ function connectToCompositor() { const pid = compositorRxBuffer.readUInt32LE(off); off += 4; const fd = compositorRxBuffer.readUInt32LE(off); - compositorRxBuffer = compositorRxBuffer.slice(headerSize); + off += 4; + const numRects = compositorRxBuffer.readUInt32LE(off); + off += 4; + const fullHeader = baseHeader + numRects * 16; + if (compositorRxBuffer.length < fullHeader) + break; + const damageRects = []; + for (let i = 0;i < numRects; i++) { + damageRects.push({ + x: compositorRxBuffer.readUInt32LE(off), + y: compositorRxBuffer.readUInt32LE(off + 4), + width: compositorRxBuffer.readUInt32LE(off + 8), + height: compositorRxBuffer.readUInt32LE(off + 12) + }); + off += 16; + } + compositorRxBuffer = compositorRxBuffer.slice(fullHeader); try { const extFd = getOrOpenShmFd(windowName, pid, fd); - const size = sbStride * sbHeight; - const safeBuffer = getReusableShmBuffer(windowName, size); - let totalRead = 0; - while (totalRead < size) { - const bytesRead = fs.readSync(extFd, safeBuffer, totalRead, size - totalRead, totalRead); - if (bytesRead <= 0) - break; - totalRead += bytesRead; - } - if (totalRead === size) { - surfaceBufferPending.set(windowName, { - name: windowName, - width: sbWidth, - height: sbHeight, - stride: sbStride, - pixels: safeBuffer - }); - if (!surfaceBufferTimer) { - surfaceBufferTimer = setTimeout(flushSurfaceBuffers, 10); + const fullSize = sbStride * sbHeight; + const safeBuffer = getReusableShmBuffer(windowName, fullSize); + const hasUsableRects = damageRects.length > 0 && damageRects.length < 64; + if (hasUsableRects) { + let minY = sbHeight, maxY = 0; + for (const r of damageRects) { + const ry = Math.max(0, r.y); + const ryEnd = Math.min(sbHeight, r.y + r.height); + if (ry < minY) + minY = ry; + if (ryEnd > maxY) + maxY = ryEnd; } + if (minY < maxY) { + const readOffset = minY * sbStride; + const readLen = (maxY - minY) * sbStride; + let totalRead = 0; + while (totalRead < readLen) { + const bytesRead = fs.readSync(extFd, safeBuffer, readOffset + totalRead, readLen - totalRead, readOffset + totalRead); + if (bytesRead <= 0) + break; + totalRead += bytesRead; + } + } + } else { + let totalRead = 0; + while (totalRead < fullSize) { + const bytesRead = fs.readSync(extFd, safeBuffer, totalRead, fullSize - totalRead, totalRead); + if (bytesRead <= 0) + break; + totalRead += bytesRead; + } + } + surfaceBufferPending.set(windowName, { + name: windowName, + width: sbWidth, + height: sbHeight, + stride: sbStride, + pixels: safeBuffer, + damageRects: hasUsableRects ? damageRects : undefined + }); + if (!surfaceBufferTimer) { + surfaceBufferTimer = setTimeout(flushSurfaceBuffers, 10); } } catch (err) { const cached = shmFdCache.get(windowName); @@ -1257,6 +1297,28 @@ function connectToCompositor() { } continue; } + if (magic === MAGIC.ENV_UPDATE) { + if (compositorRxBuffer.length < 8) + break; + const jsonLen = compositorRxBuffer.readUInt32LE(4); + if (compositorRxBuffer.length < 8 + jsonLen) + break; + const jsonStr = compositorRxBuffer.slice(8, 8 + jsonLen).toString("utf8"); + compositorRxBuffer = compositorRxBuffer.slice(8 + jsonLen); + try { + const vars = JSON.parse(jsonStr); + for (const [key, value] of Object.entries(vars)) { + process.env[key] = value; + debugLog(`[Wo] env update: ${key}=${value}`); + } + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("wo:env-update", vars); + } + } catch (err) { + debugLog("[Wo] ENV_UPDATE parse error:", err); + } + continue; + } debugLog("[Wo] UNKNOWN magic 0x" + magic.toString(16).padStart(8, "0") + " bufLen=" + compositorRxBuffer.length + " — stream may be corrupted"); compositorRxBuffer = compositorRxBuffer.slice(1); } diff --git a/electron/src/main.ts b/electron/src/main.ts index 74c166b..fd5a211 100644 --- a/electron/src/main.ts +++ b/electron/src/main.ts @@ -71,7 +71,7 @@ let damageBuffer: DamageBuffer | null = null; let nativeDmabuf: any = null; let compositorRxBuffer = Buffer.alloc(0); // Per-window surface buffer throttle (~30fps max delivery to renderer) -const surfaceBufferPending = new Map(); +const surfaceBufferPending = new Map }>(); let surfaceBufferTimer: NodeJS.Timeout | null = null; const shmReadBufferCache = new Map(); const shmFdCache = new Map(); @@ -1044,8 +1044,9 @@ function connectToCompositor(): Promise { if (magic === MAGIC.SHM_BUFFER) { if (compositorRxBuffer.length < 8) break; const nameLen = compositorRxBuffer.readUInt32LE(4); - const headerSize = 8 + nameLen + 20; - if (compositorRxBuffer.length < headerSize) break; + // Minimum header before we know num_rects + const baseHeader = 8 + nameLen + 20 + 4; // +4 for num_rects field + if (compositorRxBuffer.length < baseHeader) break; let off = 8; const windowName = compositorRxBuffer.slice(off, off + nameLen).toString('utf8'); off += nameLen; @@ -1053,31 +1054,70 @@ function connectToCompositor(): Promise { const sbHeight = compositorRxBuffer.readUInt32LE(off); off += 4; const sbStride = compositorRxBuffer.readUInt32LE(off); off += 4; const pid = compositorRxBuffer.readUInt32LE(off); off += 4; - const fd = compositorRxBuffer.readUInt32LE(off); - compositorRxBuffer = compositorRxBuffer.slice(headerSize); + const fd = compositorRxBuffer.readUInt32LE(off); off += 4; + const numRects = compositorRxBuffer.readUInt32LE(off); off += 4; + const fullHeader = baseHeader + numRects * 16; + if (compositorRxBuffer.length < fullHeader) break; + + const damageRects: Array<{x: number; y: number; width: number; height: number}> = []; + for (let i = 0; i < numRects; i++) { + damageRects.push({ + x: compositorRxBuffer.readUInt32LE(off), + y: compositorRxBuffer.readUInt32LE(off + 4), + width: compositorRxBuffer.readUInt32LE(off + 8), + height: compositorRxBuffer.readUInt32LE(off + 12), + }); + off += 16; + } + compositorRxBuffer = compositorRxBuffer.slice(fullHeader); try { const extFd = getOrOpenShmFd(windowName, pid, fd); - const size = sbStride * sbHeight; - const safeBuffer = getReusableShmBuffer(windowName, size); - let totalRead = 0; - while (totalRead < size) { - const bytesRead = fs.readSync(extFd, safeBuffer, totalRead, size - totalRead, totalRead); - if (bytesRead <= 0) break; - totalRead += bytesRead; + const fullSize = sbStride * sbHeight; + const safeBuffer = getReusableShmBuffer(windowName, fullSize); + + // Partial read: only read damaged rows from memfd. + // Empty damageRects = full frame update (always do full read). + // Also do full read on first frame for this window (buffer is uninitialized). + const hasUsableRects = damageRects.length > 0 && damageRects.length < 64; + if (hasUsableRects) { + let minY = sbHeight, maxY = 0; + for (const r of damageRects) { + const ry = Math.max(0, r.y); + const ryEnd = Math.min(sbHeight, r.y + r.height); + if (ry < minY) minY = ry; + if (ryEnd > maxY) maxY = ryEnd; + } + if (minY < maxY) { + const readOffset = minY * sbStride; + const readLen = (maxY - minY) * sbStride; + let totalRead = 0; + while (totalRead < readLen) { + const bytesRead = fs.readSync(extFd, safeBuffer, readOffset + totalRead, readLen - totalRead, readOffset + totalRead); + if (bytesRead <= 0) break; + totalRead += bytesRead; + } + } + } else { + // Full read (no rects, too many rects, or first frame) + let totalRead = 0; + while (totalRead < fullSize) { + const bytesRead = fs.readSync(extFd, safeBuffer, totalRead, fullSize - totalRead, totalRead); + if (bytesRead <= 0) break; + totalRead += bytesRead; + } } - if (totalRead === size) { - surfaceBufferPending.set(windowName, { - name: windowName, - width: sbWidth, - height: sbHeight, - stride: sbStride, - pixels: safeBuffer, - }); - if (!surfaceBufferTimer) { - surfaceBufferTimer = setTimeout(flushSurfaceBuffers, 10); - } + surfaceBufferPending.set(windowName, { + name: windowName, + width: sbWidth, + height: sbHeight, + stride: sbStride, + pixels: safeBuffer, + damageRects: hasUsableRects ? damageRects : undefined, + }); + if (!surfaceBufferTimer) { + surfaceBufferTimer = setTimeout(flushSurfaceBuffers, 10); } } catch (err) { const cached = shmFdCache.get(windowName); diff --git a/electron/src/preload.ts b/electron/src/preload.ts index 780150c..5bc5719 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -51,8 +51,8 @@ const compositorAPI: CompositorAPI = { }, // Surface buffer: Wayland window pixel content from compositor - onSurfaceBuffer: (callback: (data: { name: string; width: number; height: number; stride: number; pixels: Buffer }) => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: { name: string; width: number; height: number; stride: number; pixels: Buffer }) => + onSurfaceBuffer: (callback: (data: { name: string; width: number; height: number; stride: number; pixels: Buffer; damageRects?: Array<{x: number; y: number; width: number; height: number}> }) => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: { name: string; width: number; height: number; stride: number; pixels: Buffer; damageRects?: Array<{x: number; y: number; width: number; height: number}> }) => callback(data); ipcRenderer.on('wo:surface-buffer', handler); return () => ipcRenderer.removeListener('wo:surface-buffer', handler); diff --git a/electron/types/index.ts b/electron/types/index.ts index 921133e..5f124f5 100644 --- a/electron/types/index.ts +++ b/electron/types/index.ts @@ -17,6 +17,8 @@ export type { WindowMetadataEvent, FocusChangeEvent, DamageFramePayload, + DamageRect, + DamagePatch, RendererDamageHelper, RendererDamageHelperOptions, } from './types'; diff --git a/electron/types/types.d.ts b/electron/types/types.d.ts index 89059c6..eee5c27 100644 --- a/electron/types/types.d.ts +++ b/electron/types/types.d.ts @@ -302,6 +302,7 @@ export interface SurfaceBufferData { height: number; stride: number; pixels: Uint8Array; + damageRects?: DamageRect[]; } /** diff --git a/src/electron.rs b/src/electron.rs index 93bfc06..e09cc7f 100644 --- a/src/electron.rs +++ b/src/electron.rs @@ -227,6 +227,9 @@ impl ElectronClientConnection { /// Wire format: magic(4) + name_len(4) + name(N) + width(4) + height(4) + stride(4) + pid(4) + fd(4) /// The compositor PID and fd number let Electron open /proc//fd/ /// to mmap pixels without copying. + /// Wire format: + /// magic(4) + name_len(4) + name(N) + width(4) + height(4) + stride(4) + pid(4) + fd(4) + /// + num_rects(4) + [x(4) + y(4) + w(4) + h(4)] * num_rects pub fn send_shm_buffer( &self, window_name: &str, @@ -235,9 +238,11 @@ impl ElectronClientConnection { stride: u32, pid: u32, memfd_fd: i32, + damage_rects: &[crate::state::DamageRect], ) -> Result<()> { let name_bytes = window_name.as_bytes(); - let header_len = 4 + 4 + name_bytes.len() + 4 + 4 + 4 + 4 + 4; + let num_rects = damage_rects.len() as u32; + let header_len = 4 + 4 + name_bytes.len() + 4 + 4 + 4 + 4 + 4 + 4 + (damage_rects.len() * 16); let mut buf = vec![0u8; header_len]; let mut off = 0; buf[off..off + 4].copy_from_slice(&MAGIC_SHM_BUFFER.to_le_bytes()); @@ -255,6 +260,19 @@ impl ElectronClientConnection { buf[off..off + 4].copy_from_slice(&pid.to_le_bytes()); off += 4; buf[off..off + 4].copy_from_slice(&(memfd_fd as u32).to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&num_rects.to_le_bytes()); + off += 4; + for rect in damage_rects { + buf[off..off + 4].copy_from_slice(&(rect.x as u32).to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&(rect.y as u32).to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&(rect.width as u32).to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&(rect.height as u32).to_le_bytes()); + off += 4; + } self.write_bytes_nonblocking(&buf) } @@ -627,7 +645,10 @@ impl ElectronIpc { .with_context(|| format!("binding IPC socket {socket_path}"))?; info!(socket = socket_path, "IPC socket listening"); - let (tx, rx) = mpsc::channel(8); + // 64 slots to avoid frame/input starvation under load. The old + // value of 8 caused silent frame drops when games submitted at + // >60 FPS while input events competed for the same channel. + let (tx, rx) = mpsc::channel(64); let tx2 = tx.clone(); let clients = Arc::new(Mutex::new(HashMap::new())); let clients2 = clients.clone(); @@ -777,6 +798,7 @@ impl ElectronIpc { stride: u32, pid: u32, memfd_fd: i32, + damage_rects: &[crate::state::DamageRect], ) -> Result<()> { let snapshot: Vec<_> = { let clients = self.clients.lock().unwrap(); @@ -784,7 +806,7 @@ impl ElectronIpc { }; // clients Mutex released — safe to do I/O. for client in &snapshot { - if let Err(e) = client.send_shm_buffer(window_name, width, height, stride, pid, memfd_fd) { + if let Err(e) = client.send_shm_buffer(window_name, width, height, stride, pid, memfd_fd, damage_rects) { debug!("shm buffer to '{}' dropped: {e:#}", client.name); } } diff --git a/src/handlers/input.rs b/src/handlers/input.rs index bc3c135..e0679ac 100644 --- a/src/handlers/input.rs +++ b/src/handlers/input.rs @@ -408,10 +408,19 @@ impl WoState { if let Some(ref win_name) = under_window { if button_state == ButtonState::Pressed { - self.keyboard_window_focus = Some(win_name.clone()); - // Clear Wayland keyboard focus so Electron receives keys. - let keyboard = self.seat.get_keyboard().unwrap(); - keyboard.set_focus(self, None, serial); + // Only set keyboard focus to the Electron window and clear + // Wayland focus if we don't already have a native window + // focused via the forwarded path. If the user clicked on a + // native window canvas inside Electron/comraw, the forwarded + // pointer_button action will arrive shortly and set the + // correct Wayland surface focus. Clearing it here would + // cause focus to bounce (clear → set → clear → set). + let has_native_focus = self.seat.get_keyboard() + .map(|kb| kb.current_focus().is_some()) + .unwrap_or(false); + if !has_native_focus { + self.keyboard_window_focus = Some(win_name.clone()); + } } if let Some(ref ipc) = self.electron_ipc { @@ -520,23 +529,34 @@ impl WoState { None => return, }; - // Do NOT overwrite self.pointer_location here. The hardware input - // handler (on_pointer_motion / on_pointer_motion_abs) already set it - // to the authoritative screen position. Recomputing it from - // element_location + geo offsets uses a different coordinate system - // and causes the position to ping-pong between the hardware value - // and the recomputed value, producing visible rubberbanding. - let pointer = match self.seat.get_pointer() { Some(p) => p, None => return, }; let serial = SERIAL_COUNTER.next_serial(); - // Coordinates (x, y) are already relative to the window/surface origin. + // (x, y) from the web UI are relative to the rendered surface buffer + // origin (includes CSD decorations/shadows). + // + // Derive a consistent pointer_location from the window's compositor- + // space position so that focus math works correctly. The hardware + // pointer_location is meaningless here because it lives in the host + // compositor's coordinate space (nested mode) while our Space uses + // its own internal coordinates. + let win_loc = self.space.element_location(&window).unwrap_or_default(); + let global_x = win_loc.x as f64 + x; + let global_y = win_loc.y as f64 + y; + self.pointer_location = Point::from((global_x, global_y)); + let focus = window .surface_under(Point::::from((x, y)), WindowSurfaceType::ALL) - .map(|(s, p)| (s, p.to_f64())); + .map(|(s, offset)| { + let focus_pos: Point = ( + win_loc.x as f64 + offset.x as f64, + win_loc.y as f64 + offset.y as f64, + ).into(); + (s, focus_pos) + }); pointer.motion( self, diff --git a/src/main.rs b/src/main.rs index 4bcf9c8..194dfa0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,6 +83,9 @@ struct EventLoopData { autostart_tasks: Vec<(crate::config::AutostartConfig, std::time::Instant)>, first_render: bool, wayland_socket: ListeningSocket, + /// Unused after frame throttle rework — kept as placeholder for future + /// per-window frame stats if needed. + #[allow(dead_code)] frame_timestamps: std::collections::HashMap, gles_tex_cache: std::collections::HashMap, input_events_pending: bool, @@ -718,15 +721,17 @@ fn render_frame(data: &mut EventLoopData) { ); } - // Export dirty Wayland surfaces to Electron clients via DMABUF/SHM + // Export dirty Wayland surfaces to Electron clients via DMABUF/SHM. + // Uses Wayland damage rects to skip unchanged surfaces entirely and to + // avoid full-buffer reads when only a small region changed. if data.state.electron_ipc.is_some() { use smithay::backend::renderer::utils::RendererSurfaceStateUserData; use smithay::reexports::wayland_server::Resource; - use smithay::wayland::compositor::with_states; + use smithay::wayland::compositor::{with_states, Damage, SurfaceAttributes}; use smithay::wayland::shm::with_buffer_contents; use tracing::debug; - let mut exported_pixels: Vec<(String, u32, u32, u32, Vec)> = Vec::new(); + let mut exported_pixels: Vec = Vec::new(); for surface in &dirty_surfaces { let surface_id = surface.id(); @@ -766,6 +771,33 @@ fn render_frame(data: &mut EventLoopData) { let Some(_geo) = window_geo else { continue }; + // Extract Wayland damage rects for partial-update optimization. + // Empty damage = full frame (surfaces in dirty_surfaces always get exported). + let damage_rects: Vec = with_states(surface, |states| { + let mut attrs = states.cached_state.get::(); + let current = attrs.current(); + current.damage.iter().map(|d| { + match d { + Damage::Buffer(rect) => crate::state::DamageRect { + x: rect.loc.x, + y: rect.loc.y, + width: rect.size.w, + height: rect.size.h, + }, + Damage::Surface(rect) => { + // Surface-coordinate damage; apply buffer_scale + let scale = current.buffer_scale; + crate::state::DamageRect { + x: rect.loc.x * scale, + y: rect.loc.y * scale, + width: rect.size.w * scale, + height: rect.size.h * scale, + } + } + } + }).collect() + }); + // Try SHM buffer first (software-rendered clients) let pixels_opt: Option<(u32, u32, u32, Vec)> = with_states(surface, |states| { @@ -792,7 +824,14 @@ fn render_frame(data: &mut EventLoopData) { }); if let Some((bw, bh, stride, pixels)) = pixels_opt { - exported_pixels.push((window_name, bw, bh, stride, pixels)); + exported_pixels.push(crate::state::ShmExport { + window_name, + width: bw, + height: bh, + stride, + pixels, + damage_rects, + }); } else if let Some(ref ipc) = data.state.electron_ipc { // Try DMABUF (hardware-rendered clients / games) let dmabuf_opt = with_states(surface, |states| { @@ -867,48 +906,66 @@ fn render_frame(data: &mut EventLoopData) { data.state.damaged_windows.clear(); data.state.dirty_surfaces.clear(); - // Write exported SHM pixel buffers to memfd and broadcast to Electron + // Write exported SHM pixel buffers to memfd and broadcast to Electron. + // Uses ping-pong double buffering: write to one memfd while Electron + // reads from the other, eliminating the read/write race condition. { use std::os::unix::io::AsRawFd; use nix::sys::memfd::MFdFlags; use tracing::debug; let exports = std::mem::take(&mut data.state.pending_shm_exports); - for (window_name, w, h, stride, pixels) in exports { - let memfd_fd = data.state + for export in exports { + let pair = data.state .window_shm_buffers - .entry(window_name.clone()) + .entry(export.window_name.clone()) .or_insert_with(|| { - nix::sys::memfd::memfd_create( + let a = nix::sys::memfd::memfd_create( std::ffi::CStr::from_bytes_with_nul(b"wayland_window\0").unwrap(), MFdFlags::MFD_CLOEXEC, ) - .expect("creating memfd") + .expect("creating memfd A"); + let b = nix::sys::memfd::memfd_create( + std::ffi::CStr::from_bytes_with_nul(b"wayland_window\0").unwrap(), + MFdFlags::MFD_CLOEXEC, + ) + .expect("creating memfd B"); + [a, b] }); - let size = pixels.len() as i64; - let stat = nix::sys::stat::fstat(&*memfd_fd).unwrap(); + // Pick the write slot (alternates each frame) + let idx = data.state.window_shm_write_idx + .entry(export.window_name.clone()) + .or_insert(0); + let memfd_fd = &pair[*idx]; + + let size = export.pixels.len() as i64; + let stat = nix::sys::stat::fstat(memfd_fd).unwrap(); if stat.st_size != size { - nix::unistd::ftruncate(&*memfd_fd, size).expect("truncating memfd"); + nix::unistd::ftruncate(memfd_fd, size).expect("truncating memfd"); } - nix::unistd::lseek(&*memfd_fd, 0, nix::unistd::Whence::SeekSet) + nix::unistd::lseek(memfd_fd, 0, nix::unistd::Whence::SeekSet) .expect("seeking memfd"); - nix::unistd::write(&*memfd_fd, &pixels).expect("writing to memfd"); + nix::unistd::write(memfd_fd, &export.pixels).expect("writing to memfd"); if let Some(ref ipc) = data.state.electron_ipc { let pid = std::process::id(); if let Err(e) = ipc.broadcast_shm_buffer( - &window_name, - w, - h, - stride, + &export.window_name, + export.width, + export.height, + export.stride, pid, memfd_fd.as_raw_fd() as i32, + &export.damage_rects, ) { - debug!("DRM shm buffer broadcast failed for {}: {}", window_name, e); + debug!("DRM shm buffer broadcast failed for {}: {}", export.window_name, e); } } + + // Flip to the other buffer for next frame + *idx = 1 - *idx; } } @@ -1803,31 +1860,25 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { } } - // Process frames with backpressure (8ms throttle per window) - let now = Instant::now(); + // Coalesce frames: keep only the latest frame per window. + // Previous approach hard-dropped frames within 8ms which could + // lose the newest frame when a burst arrived. Now we always + // import the freshest frame; intermediates are simply overwritten. let mut latest_frames = std::collections::HashMap::new(); for msg in frames_batch { if let crate::electron::ElectronMessage::Frame(frame) = msg { let name = frame.name.clone(); - - if let Some(last_ts) = data.frame_timestamps.get(&name) { - if now.duration_since(*last_ts) < Duration::from_millis(8) { - trace!("Dropping frame {} due to backpressure", name); - continue; - } - } - - data.frame_timestamps.insert(name.clone(), now); latest_frames.insert(name, frame); } } - // Import latest frames + // Import latest frames (non-blocking: try_lock avoids stalling + // the event loop when the renderer is busy with a page flip). for (_name, frame) in latest_frames { let frame_name = frame.name.clone(); let frame_dims = (frame.width, frame.height); if let Some(renderer_arc) = data.state.backend.renderer() { - match renderer_arc.lock() { + match renderer_arc.try_lock() { Ok(ref mut renderer) => { match import_electron_frame(renderer, frame) { Ok((tex, cached)) => { @@ -1846,7 +1897,13 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { Err(e) => warn!("DMABUF import failed for '{}': {e:#}", frame_name), } } - Err(e) => warn!("Failed to lock renderer: {e}"), + Err(_) => { + // Renderer is held by the render path; the cached + // texture from the previous frame is still valid so + // the display won't flicker. The next event-loop + // tick will pick up any newer frame. + trace!("renderer busy, deferring frame import for '{}'", frame_name); + } } } } diff --git a/src/nested.rs b/src/nested.rs index 50c9df2..16e1e69 100644 --- a/src/nested.rs +++ b/src/nested.rs @@ -747,9 +747,10 @@ pub fn run_nested(config: Config) -> Result<()> { drop(target); // Export dirty Wayland surfaces via offscreen rendering to SHM - let mut exported_pixels: Vec<(String, u32, u32, u32, Vec)> = Vec::new(); + let mut exported_pixels: Vec = Vec::new(); { use smithay::reexports::wayland_server::Resource; + use smithay::wayland::compositor::{with_states, Damage, SurfaceAttributes}; let dirty: Vec<_> = state.dirty_surfaces.drain().collect(); @@ -788,10 +789,33 @@ pub fn run_nested(config: Config) -> Result<()> { let Some(geo) = window_geo else { continue }; + // Extract Wayland damage rects for partial-update optimization. + // Empty damage = full frame (surfaces in dirty list always get exported). + let damage_rects: Vec = with_states(&surface, |states| { + let mut attrs = states.cached_state.get::(); + let current = attrs.current(); + current.damage.iter().map(|d| { + match d { + Damage::Buffer(rect) => crate::state::DamageRect { + x: rect.loc.x, + y: rect.loc.y, + width: rect.size.w, + height: rect.size.h, + }, + Damage::Surface(rect) => { + let scale = current.buffer_scale; + crate::state::DamageRect { + x: rect.loc.x * scale, + y: rect.loc.y * scale, + width: rect.size.w * scale, + height: rect.size.h * scale, + } + } + } + }).collect() + }); + // Calculate bbox (bounding box) for CSD windows with shadows/decorations - // geo.loc is the offset from surface origin to content area - // For libadwaita: geo.loc is typically (10, 10) for 10px shadows - // bbox needs to include the decorations, so expand by geo.loc on both sides let bbox_w = (geo.size.w + geo.loc.x.max(0) * 2) as u32; let bbox_h = (geo.size.h + geo.loc.y.max(0) * 2) as u32; @@ -801,9 +825,7 @@ pub fn run_nested(config: Config) -> Result<()> { } // Read SHM buffer directly (for software-rendered clients) - // TODO: Implement GL offscreen rendering for DMABUF/hardware clients use smithay::backend::renderer::utils::RendererSurfaceStateUserData; - use smithay::wayland::compositor::with_states; use smithay::wayland::shm::with_buffer_contents; let pixels_opt: Option<(u32, u32, u32, Vec)> = @@ -831,7 +853,14 @@ pub fn run_nested(config: Config) -> Result<()> { }); if let Some((bw, bh, stride, pixels)) = pixels_opt { - exported_pixels.push((window_name, bw, bh, stride, pixels)); + exported_pixels.push(crate::state::ShmExport { + window_name, + width: bw, + height: bh, + stride, + pixels, + damage_rects, + }); } else if let Some(ref ipc) = state.electron_ipc { // If no SHM pixels, check if it's a DMABUF (optimal for games) let dmabuf_opt = with_states(&surface, |states| { @@ -970,49 +999,64 @@ pub fn run_nested(config: Config) -> Result<()> { }); } - // Write exported pixel buffers to SHM and broadcast to Electron + // Write exported pixel buffers to SHM and broadcast to Electron. + // Uses ping-pong double-buffered memfds (same as DRM path). if did_bind { use std::os::unix::io::AsRawFd; let exports = std::mem::take(&mut state.pending_shm_exports); - for (window_name, w, h, stride, pixels) in exports { - let memfd_fd = state + for export in exports { + let pair = state .window_shm_buffers - .entry(window_name.clone()) + .entry(export.window_name.clone()) .or_insert_with(|| { - nix::sys::memfd::memfd_create( + let a = nix::sys::memfd::memfd_create( std::ffi::CStr::from_bytes_with_nul(b"wayland_window\0").unwrap(), MFdFlags::MFD_CLOEXEC, ) - .expect("creating memfd") + .expect("creating memfd A"); + let b = nix::sys::memfd::memfd_create( + std::ffi::CStr::from_bytes_with_nul(b"wayland_window\0").unwrap(), + MFdFlags::MFD_CLOEXEC, + ) + .expect("creating memfd B"); + [a, b] }); - let size = pixels.len() as i64; - let stat = nix::sys::stat::fstat(&*memfd_fd).unwrap(); + let idx = state.window_shm_write_idx + .entry(export.window_name.clone()) + .or_insert(0); + let memfd_fd = &pair[*idx]; + + let size = export.pixels.len() as i64; + let stat = nix::sys::stat::fstat(memfd_fd).unwrap(); if stat.st_size != size { - nix::unistd::ftruncate(&*memfd_fd, size).expect("truncating memfd"); + nix::unistd::ftruncate(memfd_fd, size).expect("truncating memfd"); } - nix::unistd::lseek(&*memfd_fd, 0, nix::unistd::Whence::SeekSet) + nix::unistd::lseek(memfd_fd, 0, nix::unistd::Whence::SeekSet) .expect("seeking memfd"); - nix::unistd::write(&*memfd_fd, &pixels).expect("writing to memfd"); + nix::unistd::write(memfd_fd, &export.pixels).expect("writing to memfd"); if let Some(ref ipc) = state.electron_ipc { let pid = std::process::id(); if let Err(e) = ipc.broadcast_shm_buffer( - &window_name, - w, - h, - stride, + &export.window_name, + export.width, + export.height, + export.stride, pid, memfd_fd.as_raw_fd() as i32, + &export.damage_rects, ) { debug!( "nested shm buffer broadcast failed for {}: {}", - window_name, e + export.window_name, e ); } } + + *idx = 1 - *idx; } } @@ -1356,25 +1400,26 @@ fn process_electron_message( .and_then(|v| v.as_f64()) .unwrap_or(0.0); - let geo = window.geometry(); - let surface_x = px; - let surface_y = py; + // Derive pointer_location from the window's + // compositor-space position so focus math is + // consistent (the hardware pointer lives in host + // compositor space and is not meaningful here). + state.pointer_location = ( + loc.x as f64 + px, + loc.y as f64 + py, + ).into(); - // Do NOT overwrite state.pointer_location. - // The hardware input handler already set it - // to the authoritative position; recomputing - // from element_location + geo causes ping-pong. let under = window.surface_under( - (surface_x, surface_y), + (px, py), smithay::desktop::WindowSurfaceType::ALL, ); let focus = under.map(|(s, off)| { - let surface_global: Point = ( - loc.x as f64 - geo.loc.x.max(0) as f64 + off.x as f64, - loc.y as f64 - geo.loc.y.max(0) as f64 + off.y as f64, + let focus_pos: Point = ( + loc.x as f64 + off.x as f64, + loc.y as f64 + off.y as f64, ) .into(); - (s, surface_global) + (s, focus_pos) }); let time = event_start_time.elapsed().as_millis() as u32; if let Some(pointer) = state.seat.get_pointer() { @@ -1418,12 +1463,39 @@ fn process_electron_message( .and_then(|v| v.as_f64()) .unwrap_or(0.0); - let geo = window.geometry(); - let surface_x = px; - let surface_y = py; + // Update pointer_location consistently + state.pointer_location = ( + loc.x as f64 + px, + loc.y as f64 + py, + ).into(); + + // Send motion first so focus is correct for the button + let under = window.surface_under( + (px, py), + smithay::desktop::WindowSurfaceType::ALL, + ); + let focus = under.map(|(s, off)| { + let focus_pos: Point = ( + loc.x as f64 + off.x as f64, + loc.y as f64 + off.y as f64, + ).into(); + (s, focus_pos) + }); + let time = event_start_time.elapsed().as_millis() as u32; + if let Some(pointer) = state.seat.get_pointer() { + let serial = smithay::utils::SERIAL_COUNTER.next_serial(); + pointer.motion( + state, + focus.clone(), + &smithay::input::pointer::MotionEvent { + location: state.pointer_location, + serial, + time, + }, + ); + pointer.frame(state); + } - // Do NOT overwrite state.pointer_location - // (same rationale as pointer_motion above). if pressed { let surface = window .toplevel() @@ -1444,7 +1516,6 @@ fn process_electron_message( } else { smithay::backend::input::ButtonState::Released }; - let time = event_start_time.elapsed().as_millis() as u32; if let Some(pointer) = state.seat.get_pointer() { let serial = smithay::utils::SERIAL_COUNTER.next_serial(); pointer.button( diff --git a/src/state.rs b/src/state.rs index 97070a4..9bbb836 100644 --- a/src/state.rs +++ b/src/state.rs @@ -36,6 +36,25 @@ use crate::{ use portal::WoPortal; use smithay::backend::allocator::dmabuf::Dmabuf; +/// A damage rectangle in buffer coordinates. +#[derive(Debug, Clone)] +pub struct DamageRect { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +/// Buffered SHM pixel export with per-surface damage information. +pub struct ShmExport { + pub window_name: String, + pub width: u32, + pub height: u32, + pub stride: u32, + pub pixels: Vec, + pub damage_rects: Vec, +} + #[derive(Default)] pub struct ClientData { pub compositor_state: CompositorClientState, @@ -107,10 +126,13 @@ pub struct WoState { pub metadata_dirty: bool, /// Wayland surfaces that committed a new buffer and need offscreen capture. pub dirty_surfaces: std::collections::HashSet, - /// Persistent SHM file descriptors (memfds) for each window. - pub window_shm_buffers: std::collections::HashMap, + /// Ping-pong SHM memfd pairs per window. Index 0 is being written by + /// the compositor while Electron reads index 1 (and vice versa). + pub window_shm_buffers: std::collections::HashMap, + /// Tracks which of the two memfds is the current write target (0 or 1). + pub window_shm_write_idx: std::collections::HashMap, /// Buffered pixel exports from offscreen rendering, written to SHM after render block. - pub pending_shm_exports: Vec<(String, u32, u32, u32, Vec)>, + pub pending_shm_exports: Vec, pub grab_state: Option, pub cursor_theme_manager: CursorThemeManager, @@ -307,6 +329,7 @@ impl WoState { metadata_dirty: true, dirty_surfaces: std::collections::HashSet::new(), window_shm_buffers: std::collections::HashMap::new(), + window_shm_write_idx: std::collections::HashMap::new(), pending_shm_exports: Vec::new(), grab_state: None, cursor_theme_manager,