This commit is contained in:
Stephen Hellings 2026-03-09 21:32:16 -04:00
parent 4fe557c536
commit 42b111410c
10 changed files with 440 additions and 142 deletions

108
electron/dist/main.js vendored
View file

@ -45,7 +45,8 @@ var MAGIC = {
FORWARD_POINTER: 1464815685, FORWARD_POINTER: 1464815685,
FORWARD_KEYBOARD: 1464814405, FORWARD_KEYBOARD: 1464814405,
FORWARD_RELATIVE_POINTER: 1464816197, FORWARD_RELATIVE_POINTER: 1464816197,
POINTER_LOCK_REQUEST: 1464815692 POINTER_LOCK_REQUEST: 1464815692,
ENV_UPDATE: 1464812885
}; };
function stringToBuffer(str) { function stringToBuffer(str) {
return Buffer.from(str, "utf-8"); return Buffer.from(str, "utf-8");
@ -1143,8 +1144,8 @@ function connectToCompositor() {
if (compositorRxBuffer.length < 8) if (compositorRxBuffer.length < 8)
break; break;
const nameLen = compositorRxBuffer.readUInt32LE(4); const nameLen = compositorRxBuffer.readUInt32LE(4);
const headerSize = 8 + nameLen + 20; const baseHeader = 8 + nameLen + 20 + 4;
if (compositorRxBuffer.length < headerSize) if (compositorRxBuffer.length < baseHeader)
break; break;
let off = 8; let off = 8;
const windowName = compositorRxBuffer.slice(off, off + nameLen).toString("utf8"); const windowName = compositorRxBuffer.slice(off, off + nameLen).toString("utf8");
@ -1158,29 +1159,68 @@ function connectToCompositor() {
const pid = compositorRxBuffer.readUInt32LE(off); const pid = compositorRxBuffer.readUInt32LE(off);
off += 4; off += 4;
const fd = compositorRxBuffer.readUInt32LE(off); 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 { try {
const extFd = getOrOpenShmFd(windowName, pid, fd); const extFd = getOrOpenShmFd(windowName, pid, fd);
const size = sbStride * sbHeight; const fullSize = sbStride * sbHeight;
const safeBuffer = getReusableShmBuffer(windowName, size); const safeBuffer = getReusableShmBuffer(windowName, fullSize);
let totalRead = 0; const hasUsableRects = damageRects.length > 0 && damageRects.length < 64;
while (totalRead < size) { if (hasUsableRects) {
const bytesRead = fs.readSync(extFd, safeBuffer, totalRead, size - totalRead, totalRead); let minY = sbHeight, maxY = 0;
if (bytesRead <= 0) for (const r of damageRects) {
break; const ry = Math.max(0, r.y);
totalRead += bytesRead; const ryEnd = Math.min(sbHeight, r.y + r.height);
} if (ry < minY)
if (totalRead === size) { minY = ry;
surfaceBufferPending.set(windowName, { if (ryEnd > maxY)
name: windowName, maxY = ryEnd;
width: sbWidth,
height: sbHeight,
stride: sbStride,
pixels: safeBuffer
});
if (!surfaceBufferTimer) {
surfaceBufferTimer = setTimeout(flushSurfaceBuffers, 10);
} }
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) { } catch (err) {
const cached = shmFdCache.get(windowName); const cached = shmFdCache.get(windowName);
@ -1257,6 +1297,28 @@ function connectToCompositor() {
} }
continue; 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"); debugLog("[Wo] UNKNOWN magic 0x" + magic.toString(16).padStart(8, "0") + " bufLen=" + compositorRxBuffer.length + " — stream may be corrupted");
compositorRxBuffer = compositorRxBuffer.slice(1); compositorRxBuffer = compositorRxBuffer.slice(1);
} }

View file

@ -71,7 +71,7 @@ let damageBuffer: DamageBuffer | null = null;
let nativeDmabuf: any = null; let nativeDmabuf: any = null;
let compositorRxBuffer = Buffer.alloc(0); let compositorRxBuffer = Buffer.alloc(0);
// Per-window surface buffer throttle (~30fps max delivery to renderer) // Per-window surface buffer throttle (~30fps max delivery to renderer)
const surfaceBufferPending = new Map<string, { name: string; width: number; height: number; stride: number; pixels: Buffer }>(); const surfaceBufferPending = new Map<string, { name: string; width: number; height: number; stride: number; pixels: Buffer; damageRects?: Array<{x: number; y: number; width: number; height: number}> }>();
let surfaceBufferTimer: NodeJS.Timeout | null = null; let surfaceBufferTimer: NodeJS.Timeout | null = null;
const shmReadBufferCache = new Map<string, Buffer>(); const shmReadBufferCache = new Map<string, Buffer>();
const shmFdCache = new Map<string, { pid: number; fd: number; extFd: number }>(); const shmFdCache = new Map<string, { pid: number; fd: number; extFd: number }>();
@ -1044,8 +1044,9 @@ function connectToCompositor(): Promise<void> {
if (magic === MAGIC.SHM_BUFFER) { if (magic === MAGIC.SHM_BUFFER) {
if (compositorRxBuffer.length < 8) break; if (compositorRxBuffer.length < 8) break;
const nameLen = compositorRxBuffer.readUInt32LE(4); const nameLen = compositorRxBuffer.readUInt32LE(4);
const headerSize = 8 + nameLen + 20; // Minimum header before we know num_rects
if (compositorRxBuffer.length < headerSize) break; const baseHeader = 8 + nameLen + 20 + 4; // +4 for num_rects field
if (compositorRxBuffer.length < baseHeader) break;
let off = 8; let off = 8;
const windowName = compositorRxBuffer.slice(off, off + nameLen).toString('utf8'); const windowName = compositorRxBuffer.slice(off, off + nameLen).toString('utf8');
off += nameLen; off += nameLen;
@ -1053,31 +1054,70 @@ function connectToCompositor(): Promise<void> {
const sbHeight = compositorRxBuffer.readUInt32LE(off); off += 4; const sbHeight = compositorRxBuffer.readUInt32LE(off); off += 4;
const sbStride = compositorRxBuffer.readUInt32LE(off); off += 4; const sbStride = compositorRxBuffer.readUInt32LE(off); off += 4;
const pid = compositorRxBuffer.readUInt32LE(off); off += 4; const pid = compositorRxBuffer.readUInt32LE(off); off += 4;
const fd = compositorRxBuffer.readUInt32LE(off); const fd = compositorRxBuffer.readUInt32LE(off); off += 4;
compositorRxBuffer = compositorRxBuffer.slice(headerSize); 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 { try {
const extFd = getOrOpenShmFd(windowName, pid, fd); const extFd = getOrOpenShmFd(windowName, pid, fd);
const size = sbStride * sbHeight; const fullSize = sbStride * sbHeight;
const safeBuffer = getReusableShmBuffer(windowName, size); const safeBuffer = getReusableShmBuffer(windowName, fullSize);
let totalRead = 0;
while (totalRead < size) { // Partial read: only read damaged rows from memfd.
const bytesRead = fs.readSync(extFd, safeBuffer, totalRead, size - totalRead, totalRead); // Empty damageRects = full frame update (always do full read).
if (bytesRead <= 0) break; // Also do full read on first frame for this window (buffer is uninitialized).
totalRead += bytesRead; 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, {
surfaceBufferPending.set(windowName, { name: windowName,
name: windowName, width: sbWidth,
width: sbWidth, height: sbHeight,
height: sbHeight, stride: sbStride,
stride: sbStride, pixels: safeBuffer,
pixels: safeBuffer, damageRects: hasUsableRects ? damageRects : undefined,
}); });
if (!surfaceBufferTimer) { if (!surfaceBufferTimer) {
surfaceBufferTimer = setTimeout(flushSurfaceBuffers, 10); surfaceBufferTimer = setTimeout(flushSurfaceBuffers, 10);
}
} }
} catch (err) { } catch (err) {
const cached = shmFdCache.get(windowName); const cached = shmFdCache.get(windowName);

View file

@ -51,8 +51,8 @@ const compositorAPI: CompositorAPI = {
}, },
// Surface buffer: Wayland window pixel content from compositor // Surface buffer: Wayland window pixel content from compositor
onSurfaceBuffer: (callback: (data: { name: string; width: number; height: number; stride: number; pixels: Buffer }) => void) => { 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 }) => 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); callback(data);
ipcRenderer.on('wo:surface-buffer', handler); ipcRenderer.on('wo:surface-buffer', handler);
return () => ipcRenderer.removeListener('wo:surface-buffer', handler); return () => ipcRenderer.removeListener('wo:surface-buffer', handler);

View file

@ -17,6 +17,8 @@ export type {
WindowMetadataEvent, WindowMetadataEvent,
FocusChangeEvent, FocusChangeEvent,
DamageFramePayload, DamageFramePayload,
DamageRect,
DamagePatch,
RendererDamageHelper, RendererDamageHelper,
RendererDamageHelperOptions, RendererDamageHelperOptions,
} from './types'; } from './types';

View file

@ -302,6 +302,7 @@ export interface SurfaceBufferData {
height: number; height: number;
stride: number; stride: number;
pixels: Uint8Array; pixels: Uint8Array;
damageRects?: DamageRect[];
} }
/** /**

View file

@ -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) /// 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/<pid>/fd/<fd> /// The compositor PID and fd number let Electron open /proc/<pid>/fd/<fd>
/// to mmap pixels without copying. /// 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( pub fn send_shm_buffer(
&self, &self,
window_name: &str, window_name: &str,
@ -235,9 +238,11 @@ impl ElectronClientConnection {
stride: u32, stride: u32,
pid: u32, pid: u32,
memfd_fd: i32, memfd_fd: i32,
damage_rects: &[crate::state::DamageRect],
) -> Result<()> { ) -> Result<()> {
let name_bytes = window_name.as_bytes(); 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 buf = vec![0u8; header_len];
let mut off = 0; let mut off = 0;
buf[off..off + 4].copy_from_slice(&MAGIC_SHM_BUFFER.to_le_bytes()); 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()); buf[off..off + 4].copy_from_slice(&pid.to_le_bytes());
off += 4; off += 4;
buf[off..off + 4].copy_from_slice(&(memfd_fd as u32).to_le_bytes()); 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) self.write_bytes_nonblocking(&buf)
} }
@ -627,7 +645,10 @@ impl ElectronIpc {
.with_context(|| format!("binding IPC socket {socket_path}"))?; .with_context(|| format!("binding IPC socket {socket_path}"))?;
info!(socket = socket_path, "IPC socket listening"); 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 tx2 = tx.clone();
let clients = Arc::new(Mutex::new(HashMap::new())); let clients = Arc::new(Mutex::new(HashMap::new()));
let clients2 = clients.clone(); let clients2 = clients.clone();
@ -777,6 +798,7 @@ impl ElectronIpc {
stride: u32, stride: u32,
pid: u32, pid: u32,
memfd_fd: i32, memfd_fd: i32,
damage_rects: &[crate::state::DamageRect],
) -> Result<()> { ) -> Result<()> {
let snapshot: Vec<_> = { let snapshot: Vec<_> = {
let clients = self.clients.lock().unwrap(); let clients = self.clients.lock().unwrap();
@ -784,7 +806,7 @@ impl ElectronIpc {
}; };
// clients Mutex released — safe to do I/O. // clients Mutex released — safe to do I/O.
for client in &snapshot { 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); debug!("shm buffer to '{}' dropped: {e:#}", client.name);
} }
} }

View file

@ -408,10 +408,19 @@ impl WoState {
if let Some(ref win_name) = under_window { if let Some(ref win_name) = under_window {
if button_state == ButtonState::Pressed { if button_state == ButtonState::Pressed {
self.keyboard_window_focus = Some(win_name.clone()); // Only set keyboard focus to the Electron window and clear
// Clear Wayland keyboard focus so Electron receives keys. // Wayland focus if we don't already have a native window
let keyboard = self.seat.get_keyboard().unwrap(); // focused via the forwarded path. If the user clicked on a
keyboard.set_focus(self, None, serial); // 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 { if let Some(ref ipc) = self.electron_ipc {
@ -520,23 +529,34 @@ impl WoState {
None => return, 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() { let pointer = match self.seat.get_pointer() {
Some(p) => p, Some(p) => p,
None => return, None => return,
}; };
let serial = SERIAL_COUNTER.next_serial(); 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 let focus = window
.surface_under(Point::<f64, Logical>::from((x, y)), WindowSurfaceType::ALL) .surface_under(Point::<f64, Logical>::from((x, y)), WindowSurfaceType::ALL)
.map(|(s, p)| (s, p.to_f64())); .map(|(s, offset)| {
let focus_pos: Point<f64, Logical> = (
win_loc.x as f64 + offset.x as f64,
win_loc.y as f64 + offset.y as f64,
).into();
(s, focus_pos)
});
pointer.motion( pointer.motion(
self, self,

View file

@ -83,6 +83,9 @@ struct EventLoopData {
autostart_tasks: Vec<(crate::config::AutostartConfig, std::time::Instant)>, autostart_tasks: Vec<(crate::config::AutostartConfig, std::time::Instant)>,
first_render: bool, first_render: bool,
wayland_socket: ListeningSocket, 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<String, Instant>, frame_timestamps: std::collections::HashMap<String, Instant>,
gles_tex_cache: std::collections::HashMap<String, smithay::backend::renderer::gles::GlesTexture>, gles_tex_cache: std::collections::HashMap<String, smithay::backend::renderer::gles::GlesTexture>,
input_events_pending: bool, 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() { if data.state.electron_ipc.is_some() {
use smithay::backend::renderer::utils::RendererSurfaceStateUserData; use smithay::backend::renderer::utils::RendererSurfaceStateUserData;
use smithay::reexports::wayland_server::Resource; 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 smithay::wayland::shm::with_buffer_contents;
use tracing::debug; use tracing::debug;
let mut exported_pixels: Vec<(String, u32, u32, u32, Vec<u8>)> = Vec::new(); let mut exported_pixels: Vec<crate::state::ShmExport> = Vec::new();
for surface in &dirty_surfaces { for surface in &dirty_surfaces {
let surface_id = surface.id(); let surface_id = surface.id();
@ -766,6 +771,33 @@ fn render_frame(data: &mut EventLoopData) {
let Some(_geo) = window_geo else { continue }; 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<crate::state::DamageRect> = with_states(surface, |states| {
let mut attrs = states.cached_state.get::<SurfaceAttributes>();
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) // Try SHM buffer first (software-rendered clients)
let pixels_opt: Option<(u32, u32, u32, Vec<u8>)> = let pixels_opt: Option<(u32, u32, u32, Vec<u8>)> =
with_states(surface, |states| { with_states(surface, |states| {
@ -792,7 +824,14 @@ fn render_frame(data: &mut EventLoopData) {
}); });
if let Some((bw, bh, stride, pixels)) = pixels_opt { 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 { } else if let Some(ref ipc) = data.state.electron_ipc {
// Try DMABUF (hardware-rendered clients / games) // Try DMABUF (hardware-rendered clients / games)
let dmabuf_opt = with_states(surface, |states| { let dmabuf_opt = with_states(surface, |states| {
@ -867,48 +906,66 @@ fn render_frame(data: &mut EventLoopData) {
data.state.damaged_windows.clear(); data.state.damaged_windows.clear();
data.state.dirty_surfaces.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 std::os::unix::io::AsRawFd;
use nix::sys::memfd::MFdFlags; use nix::sys::memfd::MFdFlags;
use tracing::debug; use tracing::debug;
let exports = std::mem::take(&mut data.state.pending_shm_exports); let exports = std::mem::take(&mut data.state.pending_shm_exports);
for (window_name, w, h, stride, pixels) in exports { for export in exports {
let memfd_fd = data.state let pair = data.state
.window_shm_buffers .window_shm_buffers
.entry(window_name.clone()) .entry(export.window_name.clone())
.or_insert_with(|| { .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(), std::ffi::CStr::from_bytes_with_nul(b"wayland_window\0").unwrap(),
MFdFlags::MFD_CLOEXEC, 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; // Pick the write slot (alternates each frame)
let stat = nix::sys::stat::fstat(&*memfd_fd).unwrap(); 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 { 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"); .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 { if let Some(ref ipc) = data.state.electron_ipc {
let pid = std::process::id(); let pid = std::process::id();
if let Err(e) = ipc.broadcast_shm_buffer( if let Err(e) = ipc.broadcast_shm_buffer(
&window_name, &export.window_name,
w, export.width,
h, export.height,
stride, export.stride,
pid, pid,
memfd_fd.as_raw_fd() as i32, 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) // Coalesce frames: keep only the latest frame per window.
let now = Instant::now(); // 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(); let mut latest_frames = std::collections::HashMap::new();
for msg in frames_batch { for msg in frames_batch {
if let crate::electron::ElectronMessage::Frame(frame) = msg { if let crate::electron::ElectronMessage::Frame(frame) = msg {
let name = frame.name.clone(); 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); 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 { for (_name, frame) in latest_frames {
let frame_name = frame.name.clone(); let frame_name = frame.name.clone();
let frame_dims = (frame.width, frame.height); let frame_dims = (frame.width, frame.height);
if let Some(renderer_arc) = data.state.backend.renderer() { if let Some(renderer_arc) = data.state.backend.renderer() {
match renderer_arc.lock() { match renderer_arc.try_lock() {
Ok(ref mut renderer) => { Ok(ref mut renderer) => {
match import_electron_frame(renderer, frame) { match import_electron_frame(renderer, frame) {
Ok((tex, cached)) => { 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!("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);
}
} }
} }
} }

View file

@ -747,9 +747,10 @@ pub fn run_nested(config: Config) -> Result<()> {
drop(target); drop(target);
// Export dirty Wayland surfaces via offscreen rendering to SHM // Export dirty Wayland surfaces via offscreen rendering to SHM
let mut exported_pixels: Vec<(String, u32, u32, u32, Vec<u8>)> = Vec::new(); let mut exported_pixels: Vec<crate::state::ShmExport> = Vec::new();
{ {
use smithay::reexports::wayland_server::Resource; use smithay::reexports::wayland_server::Resource;
use smithay::wayland::compositor::{with_states, Damage, SurfaceAttributes};
let dirty: Vec<_> = state.dirty_surfaces.drain().collect(); 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 }; 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<crate::state::DamageRect> = with_states(&surface, |states| {
let mut attrs = states.cached_state.get::<SurfaceAttributes>();
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 // 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_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; 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) // 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::backend::renderer::utils::RendererSurfaceStateUserData;
use smithay::wayland::compositor::with_states;
use smithay::wayland::shm::with_buffer_contents; use smithay::wayland::shm::with_buffer_contents;
let pixels_opt: Option<(u32, u32, u32, Vec<u8>)> = let pixels_opt: Option<(u32, u32, u32, Vec<u8>)> =
@ -831,7 +853,14 @@ pub fn run_nested(config: Config) -> Result<()> {
}); });
if let Some((bw, bh, stride, pixels)) = pixels_opt { 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 { } else if let Some(ref ipc) = state.electron_ipc {
// If no SHM pixels, check if it's a DMABUF (optimal for games) // If no SHM pixels, check if it's a DMABUF (optimal for games)
let dmabuf_opt = with_states(&surface, |states| { 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 { if did_bind {
use std::os::unix::io::AsRawFd; use std::os::unix::io::AsRawFd;
let exports = std::mem::take(&mut state.pending_shm_exports); let exports = std::mem::take(&mut state.pending_shm_exports);
for (window_name, w, h, stride, pixels) in exports { for export in exports {
let memfd_fd = state let pair = state
.window_shm_buffers .window_shm_buffers
.entry(window_name.clone()) .entry(export.window_name.clone())
.or_insert_with(|| { .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(), std::ffi::CStr::from_bytes_with_nul(b"wayland_window\0").unwrap(),
MFdFlags::MFD_CLOEXEC, 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 idx = state.window_shm_write_idx
let stat = nix::sys::stat::fstat(&*memfd_fd).unwrap(); .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 { 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"); .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 { if let Some(ref ipc) = state.electron_ipc {
let pid = std::process::id(); let pid = std::process::id();
if let Err(e) = ipc.broadcast_shm_buffer( if let Err(e) = ipc.broadcast_shm_buffer(
&window_name, &export.window_name,
w, export.width,
h, export.height,
stride, export.stride,
pid, pid,
memfd_fd.as_raw_fd() as i32, memfd_fd.as_raw_fd() as i32,
&export.damage_rects,
) { ) {
debug!( debug!(
"nested shm buffer broadcast failed for {}: {}", "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()) .and_then(|v| v.as_f64())
.unwrap_or(0.0); .unwrap_or(0.0);
let geo = window.geometry(); // Derive pointer_location from the window's
let surface_x = px; // compositor-space position so focus math is
let surface_y = py; // 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( let under = window.surface_under(
(surface_x, surface_y), (px, py),
smithay::desktop::WindowSurfaceType::ALL, smithay::desktop::WindowSurfaceType::ALL,
); );
let focus = under.map(|(s, off)| { let focus = under.map(|(s, off)| {
let surface_global: Point<f64, Logical> = ( let focus_pos: Point<f64, Logical> = (
loc.x as f64 - geo.loc.x.max(0) as f64 + off.x as f64, loc.x as f64 + off.x as f64,
loc.y as f64 - geo.loc.y.max(0) as f64 + off.y as f64, loc.y as f64 + off.y as f64,
) )
.into(); .into();
(s, surface_global) (s, focus_pos)
}); });
let time = event_start_time.elapsed().as_millis() as u32; let time = event_start_time.elapsed().as_millis() as u32;
if let Some(pointer) = state.seat.get_pointer() { if let Some(pointer) = state.seat.get_pointer() {
@ -1418,12 +1463,39 @@ fn process_electron_message(
.and_then(|v| v.as_f64()) .and_then(|v| v.as_f64())
.unwrap_or(0.0); .unwrap_or(0.0);
let geo = window.geometry(); // Update pointer_location consistently
let surface_x = px; state.pointer_location = (
let surface_y = py; 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<f64, Logical> = (
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 { if pressed {
let surface = window let surface = window
.toplevel() .toplevel()
@ -1444,7 +1516,6 @@ fn process_electron_message(
} else { } else {
smithay::backend::input::ButtonState::Released smithay::backend::input::ButtonState::Released
}; };
let time = event_start_time.elapsed().as_millis() as u32;
if let Some(pointer) = state.seat.get_pointer() { if let Some(pointer) = state.seat.get_pointer() {
let serial = smithay::utils::SERIAL_COUNTER.next_serial(); let serial = smithay::utils::SERIAL_COUNTER.next_serial();
pointer.button( pointer.button(

View file

@ -36,6 +36,25 @@ use crate::{
use portal::WoPortal; use portal::WoPortal;
use smithay::backend::allocator::dmabuf::Dmabuf; 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<u8>,
pub damage_rects: Vec<DamageRect>,
}
#[derive(Default)] #[derive(Default)]
pub struct ClientData { pub struct ClientData {
pub compositor_state: CompositorClientState, pub compositor_state: CompositorClientState,
@ -107,10 +126,13 @@ pub struct WoState {
pub metadata_dirty: bool, pub metadata_dirty: bool,
/// Wayland surfaces that committed a new buffer and need offscreen capture. /// Wayland surfaces that committed a new buffer and need offscreen capture.
pub dirty_surfaces: std::collections::HashSet<WlSurface>, pub dirty_surfaces: std::collections::HashSet<WlSurface>,
/// Persistent SHM file descriptors (memfds) for each window. /// Ping-pong SHM memfd pairs per window. Index 0 is being written by
pub window_shm_buffers: std::collections::HashMap<String, std::os::unix::io::OwnedFd>, /// the compositor while Electron reads index 1 (and vice versa).
pub window_shm_buffers: std::collections::HashMap<String, [std::os::unix::io::OwnedFd; 2]>,
/// Tracks which of the two memfds is the current write target (0 or 1).
pub window_shm_write_idx: std::collections::HashMap<String, usize>,
/// Buffered pixel exports from offscreen rendering, written to SHM after render block. /// Buffered pixel exports from offscreen rendering, written to SHM after render block.
pub pending_shm_exports: Vec<(String, u32, u32, u32, Vec<u8>)>, pub pending_shm_exports: Vec<ShmExport>,
pub grab_state: Option<crate::handlers::xdg_shell::GrabState>, pub grab_state: Option<crate::handlers::xdg_shell::GrabState>,
pub cursor_theme_manager: CursorThemeManager, pub cursor_theme_manager: CursorThemeManager,
@ -307,6 +329,7 @@ impl WoState {
metadata_dirty: true, metadata_dirty: true,
dirty_surfaces: std::collections::HashSet::new(), dirty_surfaces: std::collections::HashSet::new(),
window_shm_buffers: std::collections::HashMap::new(), window_shm_buffers: std::collections::HashMap::new(),
window_shm_write_idx: std::collections::HashMap::new(),
pending_shm_exports: Vec::new(), pending_shm_exports: Vec::new(),
grab_state: None, grab_state: None,
cursor_theme_manager, cursor_theme_manager,