From e245af128c0bde4658aacf214f07808730237ca9 Mon Sep 17 00:00:00 2001 From: Stephen Hellings Date: Mon, 9 Mar 2026 14:31:47 -0400 Subject: [PATCH] fix: add frame.finish() to flush GL commands, fix IPC write ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two critical fixes: 1. **Flickering (GL frame not finalized)**: render_frame was missing the frame.finish() call that nested.rs has (line 688). Without it, GL rendering commands are never flushed to the GBM BO, so the DRM page flip scans out a buffer with stale/incomplete pixel data, alternating between rendered and unrendered content every frame. 2. **IPC protocol corruption (EPIPE/ECONNRESET)**: Node's socket.write() is async and buffers data in userspace. The native sendFd() call (sendmsg with SCM_RIGHTS) goes directly to the kernel, racing ahead of the buffered header+plane bytes. The compositor then reads the 1-byte SCM_RIGHTS dummy as the next message magic, hits 'unknown magic', and the IPC reader thread exits — closing the socket (EPIPE). Fix: use fs.writeSync() on the raw fd for header+plane data so it reaches the kernel buffer before sendFd's sendmsg. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- electron/dist/main.js | 1782 +++++++++++++++++++++++++++++++++ electron/src/damage-buffer.ts | 11 +- src/electron.rs | 2 +- src/main.rs | 39 +- 4 files changed, 1823 insertions(+), 11 deletions(-) create mode 100644 electron/dist/main.js diff --git a/electron/dist/main.js b/electron/dist/main.js new file mode 100644 index 0000000..2dfe426 --- /dev/null +++ b/electron/dist/main.js @@ -0,0 +1,1782 @@ +import { createRequire } from "node:module"; +var __create = Object.create; +var __getProtoOf = Object.getPrototypeOf; +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __toESM = (mod, isNodeMode, target) => { + target = mod != null ? __create(__getProtoOf(mod)) : {}; + const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; + for (let key of __getOwnPropNames(mod)) + if (!__hasOwnProp.call(to, key)) + __defProp(to, key, { + get: () => mod[key], + enumerable: true + }); + return to; +}; +var __require = /* @__PURE__ */ createRequire(import.meta.url); + +// src/main.ts +import { app, BrowserWindow, ipcMain } from "electron"; +import { createConnection, createServer } from "net"; +import { fileURLToPath } from "url"; +import { dirname, resolve as resolvePath, join as joinPath } from "path"; +import fs from "fs"; +import { createRequire as createRequire2 } from "module"; + +// src/protocol.ts +var MAGIC = { + HELLO: 1464813644, + FRAME: 1464813138, + MOUSE_MOVE: 1464814925, + MOUSE_BUTTON: 1464814914, + KEYBOARD: 1464814402, + SCROLL: 1464816451, + ACTION: 1464812353, + FOCUS_CHANGE: 1464813123, + WINDOW_META: 1464817485, + WINDOW_POS: 1464817488, + SYSCALL: 1464816473, + FRAME_ACK: 1464813121, + SURFACE_BUFFER: 1464816450, + SHM_BUFFER: 1464816461, + DMABUF_FRAME: 1464812614, + FORWARD_POINTER: 1464815685, + FORWARD_KEYBOARD: 1464814405, + FORWARD_RELATIVE_POINTER: 1464816197, + POINTER_LOCK_REQUEST: 1464815692 +}; +function stringToBuffer(str) { + return Buffer.from(str, "utf-8"); +} + +// src/damage-buffer.ts +import { writeSync } from "fs"; +var ARGB8888 = fourccCode("A", "R", "2", "4"); + +class DamageBuffer { + width; + height; + stride; + frame; + constructor(width, height, stride = width * 4) { + this.width = width; + this.height = height; + this.stride = stride; + this.frame = Buffer.alloc(this.stride * this.height); + } + getWidth() { + return this.width; + } + getHeight() { + return this.height; + } + getStride() { + return this.stride; + } + getFrameBuffer() { + return this.frame; + } + reset(width, height, stride = width * 4) { + this.width = width; + this.height = height; + this.stride = stride; + this.frame = Buffer.alloc(this.stride * this.height); + } + applyFullFrame(frame, frameStride = this.width * 4) { + if (frameStride === this.stride) { + Buffer.from(frame).copy(this.frame, 0, 0, this.frame.length); + return; + } + const rowBytes = Math.min(frameStride, this.stride); + for (let y = 0;y < this.height; y += 1) { + const srcStart = y * frameStride; + const dstStart = y * this.stride; + Buffer.from(frame).copy(this.frame, dstStart, srcStart, srcStart + rowBytes); + } + } + applyPatch(patch) { + const rect = clampRect(patch.rect, this.width, this.height); + if (rect.width <= 0 || rect.height <= 0) { + return; + } + const patchStride = patch.stride ?? rect.width * 4; + const dstStartX = rect.x * 4; + const rowBytes = rect.width * 4; + for (let row = 0;row < rect.height; row += 1) { + const srcStart = row * patchStride; + const dstStart = (rect.y + row) * this.stride + dstStartX; + Buffer.from(patch.rgba).copy(this.frame, dstStart, srcStart, srcStart + rowBytes); + } + } + applyPatches(patches) { + for (const patch of patches) { + this.applyPatch(patch); + } + } +} + +class WoDmabufSender { + socket; + windowName; + native; + seq = 1n; + constructor(socket, windowName, native) { + this.socket = socket; + this.windowName = windowName; + this.native = native; + } + send(buffer) { + const imported = this.native.importRgba(buffer.getFrameBuffer(), buffer.getWidth(), buffer.getHeight(), buffer.getStride()); + try { + const socketFd = getSocketFd(this.socket); + const nameBuf = Buffer.from(this.windowName, "utf8"); + const header = Buffer.alloc(4 + 4 + nameBuf.length + 8 + 4 + 4 + 4 + 4); + let offset = 0; + header.writeUInt32LE(MAGIC.FRAME, offset); + offset += 4; + header.writeUInt32LE(nameBuf.length, offset); + offset += 4; + nameBuf.copy(header, offset); + offset += nameBuf.length; + header.writeBigUInt64LE(this.seq, offset); + offset += 8; + header.writeUInt32LE(buffer.getWidth(), offset); + offset += 4; + header.writeUInt32LE(buffer.getHeight(), offset); + offset += 4; + header.writeUInt32LE(ARGB8888, offset); + offset += 4; + header.writeUInt32LE(1, offset); + const plane = Buffer.alloc(16); + plane.writeUInt32LE(imported.offset, 0); + plane.writeUInt32LE(imported.stride, 4); + plane.writeUInt32LE(imported.modifier_hi, 8); + plane.writeUInt32LE(imported.modifier_lo, 12); + const combined = Buffer.concat([header, plane]); + writeSync(socketFd, combined, 0, combined.length); + this.native.sendFd(socketFd, imported.fd); + const sentSeq = this.seq; + this.seq += 1n; + return sentSeq; + } finally { + this.native.releaseBuffer(imported.token); + } + } +} +function applyDamagePayload(buffer, payload) { + if (payload.width !== buffer.getWidth() || payload.height !== buffer.getHeight()) { + buffer.reset(payload.width, payload.height); + } + if (payload.fullFrame) { + buffer.applyFullFrame(payload.fullFrame, payload.fullStride ?? payload.width * 4); + } + if (payload.patches && payload.patches.length > 0) { + buffer.applyPatches(payload.patches); + } +} +function loadNativeDmabufModule(requireFn, nativePath) { + const native = requireFn(nativePath); + return native; +} +function getSocketFd(socket) { + const fd = socket?._handle?.fd; + if (typeof fd !== "number" || !Number.isFinite(fd) || fd < 0) { + throw new Error("Unable to read underlying socket file descriptor"); + } + return fd; +} +function clampRect(rect, width, height) { + const x = Math.max(0, Math.min(width, rect.x)); + const y = Math.max(0, Math.min(height, rect.y)); + const right = Math.max(x, Math.min(width, rect.x + rect.width)); + const bottom = Math.max(y, Math.min(height, rect.y + rect.height)); + return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) }; +} +function fourccCode(a, b, c, d) { + return (a.charCodeAt(0) | b.charCodeAt(0) << 8 | c.charCodeAt(0) << 16 | d.charCodeAt(0) << 24) >>> 0; +} + +// src/main.ts +var __filename2 = fileURLToPath(import.meta.url); +var __dirname2 = dirname(__filename2); +var require2 = createRequire2(import.meta.url); +var DEBUG_LOG = "/tmp/wo-electron-debug.log"; +function debugLog(...args) { + const msg = `[${new Date().toISOString()}] ` + args.join(" ") + ` +`; + try { + fs.appendFileSync(DEBUG_LOG, msg); + } catch (e) {} + console.log(...args); +} +debugLog("[Wo] Electron starting, NODE_VERSION=", process.version); +var IPC_SOCKET = process.env.WO_IPC_SOCKET || "/run/user/1000/wo-ipc.sock"; +debugLog("[Wo] WO_WINDOW_CONFIG env =", process.env.WO_WINDOW_CONFIG); +var WINDOW_CONFIG = JSON.parse(process.env.WO_WINDOW_CONFIG || "{}"); +debugLog("[Wo] WINDOW_CONFIG parsed =", JSON.stringify(WINDOW_CONFIG)); +var CLIENT_MODE = process.env.WO_CLIENT_MODE === "1"; +var { + name = "default", + url = null, + html = null, + width = 1920, + height = 1080 +} = WINDOW_CONFIG; +var mainWindow = null; +var ipcSocket = null; +var portalUiServer = null; +var portalPendingRequests = new Map; +var PORTAL_UI_SOCKET = "/tmp/wo-portal-ui.sock"; +var ipcConnected = false; +var ipcReconnectTimer = null; +var windowPositionUpdateTimer = null; +var dmabufSender = null; +var damageBuffer = null; +var nativeDmabuf = null; +var compositorRxBuffer = Buffer.alloc(0); +var surfaceBufferPending = new Map; +var surfaceBufferTimer = null; +var shmReadBufferCache = new Map; +var shmFdCache = new Map; +function closeShmFdCache() { + for (const entry of shmFdCache.values()) { + try { + fs.closeSync(entry.extFd); + } catch {} + } + shmFdCache.clear(); +} +function getOrOpenShmFd(windowName, pid, fd) { + const cached = shmFdCache.get(windowName); + if (cached && cached.pid === pid && cached.fd === fd) { + return cached.extFd; + } + if (cached) { + try { + fs.closeSync(cached.extFd); + } catch {} + } + const fdPath = `/proc/${pid}/fd/${fd}`; + const extFd = fs.openSync(fdPath, "r"); + shmFdCache.set(windowName, { pid, fd, extFd }); + return extFd; +} +function getReusableShmBuffer(windowName, size) { + const existing = shmReadBufferCache.get(windowName); + if (existing && existing.length >= size) { + return existing; + } + const next = Buffer.allocUnsafe(size); + shmReadBufferCache.set(windowName, next); + return next; +} +function flushSurfaceBuffers() { + surfaceBufferTimer = null; + if (!mainWindow || mainWindow.isDestroyed()) { + surfaceBufferPending.clear(); + return; + } + for (const entry of surfaceBufferPending.values()) { + mainWindow.webContents.send("wo:surface-buffer", entry); + } + surfaceBufferPending.clear(); +} +var inFlightFrameSeqs = new Set; +var MAX_IN_FLIGHT_FRAMES = 2; +var appUsedDamageHelper = false; +var pointerX = 0; +var pointerY = 0; +function respondToPortalRequest(requestId, params) { + const pending = portalPendingRequests.get(requestId); + if (!pending) { + return { ok: false, error: `No pending portal request: ${requestId}` }; + } + let response; + if (pending.kind === "screen_share") { + response = { + allowed: Boolean(params.allowed), + sourceType: params.type === "window" ? "Window" : "Monitor", + windowName: typeof params.windowName === "string" ? params.windowName : null + }; + } else { + response = { allowed: false }; + } + try { + pending.socket.write(`${JSON.stringify(response)} +`); + pending.socket.end(); + portalPendingRequests.delete(requestId); + return { ok: true }; + } catch (error) { + portalPendingRequests.delete(requestId); + return { ok: false, error: `Failed to send portal response: ${String(error)}` }; + } +} +function startPortalUiBridge() { + try { + if (fs.existsSync(PORTAL_UI_SOCKET)) { + fs.unlinkSync(PORTAL_UI_SOCKET); + } + } catch {} + portalUiServer = createServer((socket) => { + let received = ""; + socket.on("data", (chunk) => { + received += chunk.toString("utf8"); + const nl = received.indexOf(` +`); + if (nl < 0) + return; + const line = received.slice(0, nl).trim(); + received = received.slice(nl + 1); + if (!line) + return; + try { + const req = JSON.parse(line); + if (req.type !== "screen_share_request" || !req.sessionId) { + socket.end(`{"allowed":false,"reason":"invalid_request"} +`); + return; + } + const requestId = req.sessionId; + portalPendingRequests.set(requestId, { + socket, + kind: "screen_share", + sessionId: req.sessionId + }); + socket.setTimeout(90000, () => { + const pending = portalPendingRequests.get(requestId); + if (pending) { + portalPendingRequests.delete(requestId); + pending.socket.end(`{"allowed":false,"reason":"timeout"} +`); + } + }); + if (mainWindow && !mainWindow.isDestroyed()) { + const payload = { + requestId, + kind: "screen_share", + appName: req.appName || "Application", + sessionId: req.sessionId + }; + mainWindow.webContents.send("wo:portal-request", payload); + } else { + portalPendingRequests.delete(requestId); + socket.end(`{"allowed":false,"reason":"no_ui"} +`); + } + } catch { + socket.end(`{"allowed":false,"reason":"invalid_json"} +`); + } + }); + socket.on("close", () => { + for (const [sessionId, pending] of portalPendingRequests.entries()) { + if (pending.socket === socket) { + portalPendingRequests.delete(sessionId); + break; + } + } + }); + socket.on("error", () => {}); + }); + portalUiServer.listen(PORTAL_UI_SOCKET, () => { + debugLog("[Wo] Portal UI bridge listening at", PORTAL_UI_SOCKET); + }); + portalUiServer.on("error", (err) => { + console.error("[Wo] Portal UI bridge error:", err); + }); +} +function stopPortalUiBridge() { + for (const pending of portalPendingRequests.values()) { + try { + pending.socket.end(`{"allowed":false,"reason":"shutdown"} +`); + } catch {} + } + portalPendingRequests.clear(); + if (portalUiServer) { + portalUiServer.close(); + portalUiServer = null; + } + try { + if (fs.existsSync(PORTAL_UI_SOCKET)) { + fs.unlinkSync(PORTAL_UI_SOCKET); + } + } catch {} +} +function parseClientWindows() { + try { + const raw = process.env.WO_CLIENT_WINDOWS; + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed.map((w, idx) => ({ + id: String(w.id ?? w.name ?? `win-${idx + 1}`), + title: String(w.title ?? w.name ?? `Window ${idx + 1}`), + width: Number(w.width ?? 960), + height: Number(w.height ?? 540) + })); + } + } + } catch (error) { + console.warn("[Wo client] invalid WO_CLIENT_WINDOWS:", error); + } + return [{ id: String(name), title: String(name), width: Number(width), height: Number(height) }]; +} +var clientWindows = parseClientWindows(); +var compositorWindows = []; +function parseQuotedStrings(output) { + const matches = output.matchAll(/"((?:[^"\\]|\\.)*)"/g); + const values = []; + for (const match of matches) { + const raw = match[1]; + values.push(raw.replace(/\\"/g, '"').replace(/\\\\/g, "\\")); + } + return values; +} +function runBusctlUser(args) { + try { + const { execFileSync } = require2("child_process"); + return execFileSync("busctl", ["--user", ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim(); + } catch { + return ""; + } +} +function parseServiceAndPath(itemRef) { + const slashIdx = itemRef.indexOf("/"); + if (slashIdx > 0) { + return { + service: itemRef.slice(0, slashIdx), + objectPath: itemRef.slice(slashIdx) + }; + } + if (!itemRef) { + return null; + } + return { + service: itemRef, + objectPath: "/StatusNotifierItem" + }; +} +function parseBusctlStringValue(output) { + const quoted = parseQuotedStrings(output); + if (quoted.length > 0) { + return quoted[0]; + } + const parts = output.split(/\s+/); + return parts.length >= 2 ? parts.slice(1).join(" ").trim() : ""; +} +function getBusProperty(service, objectPath, iface, prop) { + const out = runBusctlUser(["get-property", service, objectPath, iface, prop]); + return out ? parseBusctlStringValue(out) : ""; +} +function mapTrayIcon(iconName, title) { + const n = iconName.toLowerCase(); + const t = title.toLowerCase(); + if (n.includes("network") || t.includes("network") || t.includes("wifi")) + return "mdi:wifi"; + if (n.includes("audio") || n.includes("volume") || t.includes("volume")) + return "mdi:volume-high"; + if (n.includes("battery") || t.includes("battery")) + return "mdi:battery"; + if (n.includes("bluetooth") || t.includes("bluetooth")) + return "mdi:bluetooth"; + if (n.includes("telegram") || t.includes("telegram")) + return "mdi:telegram"; + if (n.includes("discord") || t.includes("discord")) + return "mdi:discord"; + if (n.includes("steam") || t.includes("steam")) + return "mdi:steam"; + if (n.includes("dropbox") || t.includes("dropbox")) + return "mdi:dropbox"; + if (n.includes("mail") || t.includes("mail")) + return "mdi:email-outline"; + if (n.includes("kde") || t.includes("kde")) + return "mdi:kde"; + return "mdi:circle-medium"; +} +function iconPathCandidates(iconName) { + const direct = iconName.startsWith("/") ? [iconName] : []; + if (iconName.startsWith("/")) { + return direct; + } + const exts = ["png", "svg"]; + const bases = [ + "/usr/share/pixmaps", + "/usr/share/icons/hicolor/16x16/apps", + "/usr/share/icons/hicolor/22x22/apps", + "/usr/share/icons/hicolor/24x24/apps", + "/usr/share/icons/hicolor/32x32/apps", + "/usr/share/icons/hicolor/48x48/apps", + "/usr/share/icons/hicolor/scalable/apps", + "/usr/share/icons/breeze/apps/22", + "/usr/share/icons/breeze/apps/24", + "/usr/share/icons/breeze/apps/32" + ]; + const results = []; + for (const base of bases) { + for (const ext of exts) { + results.push(`${base}/${iconName}.${ext}`); + } + } + return results; +} +function toDataUrlForIconFile(iconPath) { + try { + if (!fs.existsSync(iconPath)) { + return; + } + const lower = iconPath.toLowerCase(); + const mime = lower.endsWith(".svg") ? "image/svg+xml" : lower.endsWith(".png") ? "image/png" : ""; + if (!mime) { + return; + } + const base64 = fs.readFileSync(iconPath).toString("base64"); + return `data:${mime};base64,${base64}`; + } catch { + return; + } +} +function resolveTrayIconDataUrl(iconName) { + for (const candidate of iconPathCandidates(iconName)) { + const dataUrl = toDataUrlForIconFile(candidate); + if (dataUrl) { + return dataUrl; + } + } + return; +} +function getStatusNotifierTrayItems() { + const watcherCall = runBusctlUser([ + "call", + "org.kde.StatusNotifierWatcher", + "/StatusNotifierWatcher", + "org.kde.StatusNotifierWatcher", + "RegisteredStatusNotifierItems" + ]); + if (!watcherCall) { + return []; + } + const refs = parseQuotedStrings(watcherCall); + const results = []; + for (const itemRef of refs) { + const parsed = parseServiceAndPath(itemRef); + if (!parsed) { + continue; + } + const statusRaw = getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "Status"); + const title = getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "Title") || getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "Id") || parsed.service; + const iconName = getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "AttentionIconName") || getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "IconName"); + const menuPath = getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "Menu"); + const iconDataUrl = iconName ? resolveTrayIconDataUrl(iconName) : undefined; + let status = "active"; + const s = statusRaw.toLowerCase(); + if (s.includes("passive")) { + status = "passive"; + } else if (s.includes("attention") || s.includes("needsattention")) { + status = "attention"; + } + results.push({ + id: `${parsed.service}${parsed.objectPath}`, + title, + status, + icon: mapTrayIcon(iconName || title, title), + iconDataUrl, + service: parsed.service, + objectPath: parsed.objectPath, + menuPath: menuPath || undefined, + hasMenu: Boolean(menuPath && menuPath !== "/NO_DBUSMENU") + }); + } + return results; +} +function activateStatusNotifierItem(service, objectPath) { + const out = runBusctlUser([ + "call", + service, + objectPath, + "org.kde.StatusNotifierItem", + "Activate", + "ii", + "0", + "0" + ]); + return out.length > 0; +} +function secondaryActivateStatusNotifierItem(service, objectPath) { + const out = runBusctlUser([ + "call", + service, + objectPath, + "org.kde.StatusNotifierItem", + "SecondaryActivate", + "ii", + "0", + "0" + ]); + return out.length > 0; +} +function openStatusNotifierContextMenu(service, objectPath, x, y) { + const out = runBusctlUser([ + "call", + service, + objectPath, + "org.kde.StatusNotifierItem", + "ContextMenu", + "ii", + String(Math.trunc(x)), + String(Math.trunc(y)) + ]); + return out.length > 0; +} +function getDBusMenuLayout(service, menuPath) { + try { + const out = runBusctlUser([ + "call", + service, + menuPath.startsWith("/") ? menuPath : "/com/canonical/dbusmenu", + "com.canonical.dbusmenu", + "GetLayout", + "u", + "0" + ]); + if (!out) { + return []; + } + const lines = out.split(` +`).filter((l) => l.trim().length > 0); + const items = []; + let depth = 0; + for (const line of lines) { + const trimmed = line.trim(); + const newDepth = (line.match(/^\s*/)?.[0]?.length ?? 0) / 2; + if (trimmed.startsWith("(") && trimmed.includes('"')) { + const quoted = parseQuotedStrings(trimmed); + if (quoted.length > 0) { + const label = quoted[0]; + const item = { + id: label, + label, + enabled: !line.toLowerCase().includes("disabled") + }; + if (newDepth === 0) { + items.push(item); + } else if (items.length > 0 && newDepth > depth) { + const parent = items[items.length - 1]; + if (!parent.children) { + parent.children = []; + } + parent.children.push(item); + } + } + depth = newDepth; + } + } + return items; + } catch { + return []; + } +} +function triggerDBusMenuEvent(service, menuPath, menuId) { + try { + const out = runBusctlUser([ + "call", + service, + menuPath.startsWith("/") ? menuPath : "/com/canonical/dbusmenu", + "com.canonical.dbusmenu", + "Event", + "sis", + menuId, + "clicked", + "" + ]); + return out.length > 0; + } catch { + return false; + } +} +function scheduleIpcReconnect(delayMs = 1000) { + if (CLIENT_MODE || process.env.WO_STANDALONE === "1") { + return; + } + if (ipcReconnectTimer) { + return; + } + ipcReconnectTimer = setTimeout(() => { + ipcReconnectTimer = null; + connectToCompositor().then(() => { + debugLog("[Wo] IPC reconnect succeeded"); + }).catch((error) => { + debugLog("[Wo] IPC reconnect failed:", String(error)); + scheduleIpcReconnect(); + }); + }, delayMs); +} +function sendActionToCompositor(action, payload) { + if (!ipcSocket || !ipcConnected) { + return false; + } + try { + const actionBuf = stringToBuffer(action); + const payloadStr = payload ? JSON.stringify(payload) : ""; + const payloadBuf = stringToBuffer(payloadStr); + const messageBuf = Buffer.alloc(12 + actionBuf.length + payloadBuf.length); + let offset = 0; + messageBuf.writeUInt32LE(MAGIC.ACTION, offset); + offset += 4; + messageBuf.writeUInt32LE(actionBuf.length, offset); + offset += 4; + actionBuf.copy(messageBuf, offset); + offset += actionBuf.length; + messageBuf.writeUInt32LE(payloadBuf.length, offset); + offset += 4; + payloadBuf.copy(messageBuf, offset); + ipcSocket.write(messageBuf); + return true; + } catch { + return false; + } +} +function sendWindowPositionUpdate(x, y, width2, height2) { + if (!ipcSocket || !ipcConnected) { + return false; + } + try { + const nameBuf = stringToBuffer(name); + const messageBuf = Buffer.alloc(8 + nameBuf.length + 16); + let offset = 0; + messageBuf.writeUInt32LE(MAGIC.WINDOW_POS, offset); + offset += 4; + messageBuf.writeUInt32LE(nameBuf.length, offset); + offset += 4; + nameBuf.copy(messageBuf, offset); + offset += nameBuf.length; + messageBuf.writeInt32LE(x, offset); + offset += 4; + messageBuf.writeInt32LE(y, offset); + offset += 4; + messageBuf.writeUInt32LE(width2, offset); + offset += 4; + messageBuf.writeUInt32LE(height2, offset); + ipcSocket.write(messageBuf); + return true; + } catch { + return false; + } +} +function linuxButtonToElectron(btn) { + if (btn === 273) + return "right"; + if (btn === 274) + return "middle"; + return "left"; +} +var modifierState = { shift: false, control: false, alt: false, meta: false }; +var MODIFIER_KEYCODES = { + 29: "control", + 42: "shift", + 54: "shift", + 56: "alt", + 97: "control", + 100: "alt", + 125: "meta", + 126: "meta" +}; +function getModifierArray() { + const mods = []; + if (modifierState.shift) + mods.push("shift"); + if (modifierState.control) + mods.push("control"); + if (modifierState.alt) + mods.push("alt"); + if (modifierState.meta) + mods.push("meta"); + return mods; +} +var SHIFTED_CHARS = { + "1": "!", + "2": "@", + "3": "#", + "4": "$", + "5": "%", + "6": "^", + "7": "&", + "8": "*", + "9": "(", + "0": ")", + "-": "_", + "=": "+", + "[": "{", + "]": "}", + "\\": "|", + ";": ":", + "'": '"', + "`": "~", + ",": "<", + ".": ">", + "/": "?" +}; +var LINUX_KEYCODE_MAP = { + 1: "Escape", + 59: "F1", + 60: "F2", + 61: "F3", + 62: "F4", + 63: "F5", + 64: "F6", + 65: "F7", + 66: "F8", + 67: "F9", + 68: "F10", + 87: "F11", + 88: "F12", + 41: "`", + 2: "1", + 3: "2", + 4: "3", + 5: "4", + 6: "5", + 7: "6", + 8: "7", + 9: "8", + 10: "9", + 11: "0", + 12: "-", + 13: "=", + 14: "Backspace", + 15: "Tab", + 16: "q", + 17: "w", + 18: "e", + 19: "r", + 20: "t", + 21: "y", + 22: "u", + 23: "i", + 24: "o", + 25: "p", + 26: "[", + 27: "]", + 43: "\\", + 58: "CapsLock", + 30: "a", + 31: "s", + 32: "d", + 33: "f", + 34: "g", + 35: "h", + 36: "j", + 37: "k", + 38: "l", + 39: ";", + 40: "'", + 28: "Return", + 42: "Shift", + 44: "z", + 45: "x", + 46: "c", + 47: "v", + 48: "b", + 49: "n", + 50: "m", + 51: ",", + 52: ".", + 53: "/", + 54: "Shift", + 29: "Control", + 125: "Meta", + 56: "Alt", + 57: "Space", + 100: "Alt", + 126: "Meta", + 97: "Control", + 110: "Insert", + 102: "Home", + 104: "PageUp", + 111: "Delete", + 107: "End", + 109: "PageDown", + 103: "Up", + 105: "Left", + 108: "Down", + 106: "Right", + 69: "NumLock", + 98: "numdiv", + 55: "nummult", + 74: "numsub", + 71: "num7", + 72: "num8", + 73: "num9", + 78: "numadd", + 75: "num4", + 76: "num5", + 77: "num6", + 79: "num1", + 80: "num2", + 81: "num3", + 96: "Enter", + 82: "num0", + 83: "numdec", + 99: "PrintScreen", + 70: "ScrollLock", + 119: "Pause", + 113: "VolumeMute", + 114: "VolumeDown", + 115: "VolumeUp", + 163: "MediaNextTrack", + 165: "MediaPreviousTrack", + 164: "MediaPlayPause", + 166: "MediaStop" +}; +function connectToCompositor() { + return new Promise((resolve, reject) => { + if (ipcReconnectTimer) { + clearTimeout(ipcReconnectTimer); + ipcReconnectTimer = null; + } + if (ipcSocket && !ipcSocket.destroyed) { + ipcSocket.destroy(); + } + debugLog("[Wo] Connecting to compositor IPC:", IPC_SOCKET); + ipcSocket = createConnection(IPC_SOCKET, () => { + const windowName = name; + const nameBuf = stringToBuffer(windowName); + const messageBuf = Buffer.alloc(8 + nameBuf.length + 8); + let offset = 0; + messageBuf.writeUInt32LE(MAGIC.HELLO, offset); + offset += 4; + messageBuf.writeUInt32LE(nameBuf.length, offset); + offset += 4; + nameBuf.copy(messageBuf, offset); + offset += nameBuf.length; + messageBuf.writeUInt32LE(width, offset); + offset += 4; + messageBuf.writeUInt32LE(height, offset); + ipcSocket.write(messageBuf); + ipcConnected = true; + debugLog("[Wo] IPC connected, sent HELLO message"); + try { + const nativePath = resolvePath(__dirname2, "../native/build/Release/wo_dmabuf.node"); + nativeDmabuf = loadNativeDmabufModule(require2, nativePath); + nativeDmabuf.init(process.env.WO_DRM_RENDER_NODE || "/dev/dri/renderD128"); + dmabufSender = new WoDmabufSender(ipcSocket, name, nativeDmabuf); + debugLog("[Wo] DMABUF sender initialized"); + } catch (error) { + debugLog("[Wo] DMABUF sender unavailable:", error); + dmabufSender = null; + } + resolve(); + }); + let ipcDataCalls = 0; + let ipcDataLastLog = Date.now(); + let pendingMouseMove = null; + let inputFlushTimer = null; + const flushInputEvents = () => { + inputFlushTimer = null; + if (pendingMouseMove && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ + type: "mouseMove", + x: pendingMouseMove.x, + y: pendingMouseMove.y + }); + pendingMouseMove = null; + } + }; + ipcSocket.on("data", (chunk) => { + ipcDataCalls++; + const now = Date.now(); + if (now - ipcDataLastLog > 2000) { + ipcDataCalls = 0; + ipcDataLastLog = now; + } + const dataT0 = Date.now(); + compositorRxBuffer = Buffer.concat([compositorRxBuffer, chunk]); + while (compositorRxBuffer.length >= 4) { + const magic = compositorRxBuffer.readUInt32LE(0); + if (magic === MAGIC.FRAME_ACK) { + if (compositorRxBuffer.length < 12) + break; + const seq = compositorRxBuffer.readBigUInt64LE(4).toString(); + inFlightFrameSeqs.delete(seq); + compositorRxBuffer = compositorRxBuffer.slice(12); + continue; + } + if (magic === MAGIC.MOUSE_MOVE) { + if (compositorRxBuffer.length < 20) + break; + const x = compositorRxBuffer.readDoubleLE(4); + const y = compositorRxBuffer.readDoubleLE(12); + compositorRxBuffer = compositorRxBuffer.slice(20); + pointerX = Math.round(x); + pointerY = Math.round(y); + if (!inputFlushTimer) { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ type: "mouseMove", x: pointerX, y: pointerY }); + } + } else { + pendingMouseMove = { x: pointerX, y: pointerY }; + inputFlushTimer = setTimeout(flushInputEvents, 0); + } + continue; + } + if (magic === MAGIC.MOUSE_BUTTON) { + if (compositorRxBuffer.length < 16) + break; + const button = compositorRxBuffer.readUInt32LE(4); + const pressed = compositorRxBuffer.readUInt32LE(8) !== 0; + compositorRxBuffer = compositorRxBuffer.slice(16); + if (pendingMouseMove && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ + type: "mouseMove", + x: pendingMouseMove.x, + y: pendingMouseMove.y + }); + pendingMouseMove = null; + } + if (inputFlushTimer) { + clearTimeout(inputFlushTimer); + inputFlushTimer = null; + } + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ + type: pressed ? "mouseDown" : "mouseUp", + x: pointerX, + y: pointerY, + button: linuxButtonToElectron(button), + clickCount: 1, + modifiers: getModifierArray() + }); + } + continue; + } + if (magic === MAGIC.KEYBOARD) { + if (compositorRxBuffer.length < 16) + break; + const key = compositorRxBuffer.readUInt32LE(4); + const pressed = compositorRxBuffer.readUInt32LE(8) !== 0; + compositorRxBuffer = compositorRxBuffer.slice(16); + const modKey = MODIFIER_KEYCODES[key]; + if (modKey) { + modifierState[modKey] = pressed; + } + const keyCode = LINUX_KEYCODE_MAP[key]; + if (keyCode && mainWindow && !mainWindow.isDestroyed()) { + const modifiers = getModifierArray(); + mainWindow.webContents.sendInputEvent({ + type: pressed ? "keyDown" : "keyUp", + keyCode, + modifiers + }); + if (pressed && keyCode.length === 1) { + let charKey = keyCode; + if (modifierState.shift) { + if (charKey >= "a" && charKey <= "z") { + charKey = charKey.toUpperCase(); + } else if (SHIFTED_CHARS[charKey]) { + charKey = SHIFTED_CHARS[charKey]; + } + } + mainWindow.webContents.sendInputEvent({ type: "char", keyCode: charKey, modifiers }); + } + } + continue; + } + if (magic === MAGIC.SCROLL) { + if (compositorRxBuffer.length < 16) + break; + const vertical = compositorRxBuffer.readInt32LE(4); + const horizontal = compositorRxBuffer.readInt32LE(8); + compositorRxBuffer = compositorRxBuffer.slice(16); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ + type: "mouseWheel", + x: pointerX, + y: pointerY, + deltaX: horizontal, + deltaY: vertical, + canScroll: true + }); + } + continue; + } + if (magic === MAGIC.FOCUS_CHANGE) { + if (compositorRxBuffer.length < 12) + break; + const nameLen = compositorRxBuffer.readUInt32LE(4); + if (compositorRxBuffer.length < 12 + nameLen) + break; + const focused = compositorRxBuffer.readUInt32LE(8) !== 0; + const windowName = compositorRxBuffer.slice(12, 12 + nameLen).toString("utf8"); + compositorRxBuffer = compositorRxBuffer.slice(12 + nameLen); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("wo:focus-change", { window: windowName, focused }); + } + continue; + } + if (magic === MAGIC.WINDOW_META) { + if (compositorRxBuffer.length < 8) + break; + const payloadLen = compositorRxBuffer.readUInt32LE(4); + if (compositorRxBuffer.length < 8 + payloadLen) + break; + const metadata = compositorRxBuffer.slice(8, 8 + payloadLen).toString("utf8"); + compositorRxBuffer = compositorRxBuffer.slice(8 + payloadLen); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("wo:window-metadata", { type: "windowMetadata", metadata }); + try { + const parsed = JSON.parse(metadata); + if (parsed && Array.isArray(parsed.windows)) { + compositorWindows = parsed.windows; + mainWindow.webContents.send("wo:windows", compositorWindows); + } + } catch {} + } + continue; + } + 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; + let off = 8; + const windowName = compositorRxBuffer.slice(off, off + nameLen).toString("utf8"); + off += nameLen; + const sbWidth = compositorRxBuffer.readUInt32LE(off); + off += 4; + 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); + 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); + } + } + } catch (err) { + const cached = shmFdCache.get(windowName); + if (cached) { + try { + fs.closeSync(cached.extFd); + } catch {} + shmFdCache.delete(windowName); + } + debugLog(`[Wo] SHM_BUFFER fs.readSync failed for ${windowName}:`, err); + } + continue; + } + if (magic === MAGIC.DMABUF_FRAME) { + if (compositorRxBuffer.length < 8) + break; + const nameLen = compositorRxBuffer.readUInt32LE(4); + const numPlanesOffset = 8 + nameLen + 12; + if (compositorRxBuffer.length < numPlanesOffset + 4) + break; + let off = 8; + const dmabufName = compositorRxBuffer.slice(off, off + nameLen).toString("utf8"); + off += nameLen; + const dmabufW = compositorRxBuffer.readUInt32LE(off); + off += 4; + const dmabufH = compositorRxBuffer.readUInt32LE(off); + off += 4; + const dmabufFormat = compositorRxBuffer.readUInt32LE(off); + off += 4; + const numPlanes = compositorRxBuffer.readUInt32LE(off); + off += 4; + const totalSize = off + numPlanes * 24; + if (compositorRxBuffer.length < totalSize) + break; + try { + if (nativeDmabuf && ipcSocket) { + const socketFd = ipcSocket?._handle?.fd; + if (typeof socketFd === "number" && socketFd >= 0) { + const dmabufFd = nativeDmabuf.recvFd(socketFd); + if (dmabufFd >= 0) { + try { + const textureInfo = nativeDmabuf.importDmabufTexture(dmabufName, dmabufFd, dmabufW, dmabufH, dmabufFormat); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("wo:dmabuf-frame", { + name: dmabufName, + texture: textureInfo.texture, + width: textureInfo.width, + height: textureInfo.height + }); + } + } finally { + fs.closeSync(dmabufFd); + } + } + } + } + } catch (err) { + debugLog(`[Wo] DMABUF import failed for ${dmabufName}:`, err); + } + compositorRxBuffer = compositorRxBuffer.slice(totalSize); + continue; + } + if (magic === MAGIC.POINTER_LOCK_REQUEST) { + if (compositorRxBuffer.length < 12) + break; + const nameLen = compositorRxBuffer.readUInt32LE(4); + if (compositorRxBuffer.length < 12 + nameLen) + break; + const lock = compositorRxBuffer.readUInt32LE(8) !== 0; + const windowName = compositorRxBuffer.slice(12, 12 + nameLen).toString("utf8"); + compositorRxBuffer = compositorRxBuffer.slice(12 + nameLen); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("wo:pointer-lock-request", { window: windowName, lock }); + } + continue; + } + debugLog("[Wo] UNKNOWN magic 0x" + magic.toString(16).padStart(8, "0") + " bufLen=" + compositorRxBuffer.length + " — stream may be corrupted"); + compositorRxBuffer = compositorRxBuffer.slice(1); + } + const dataElapsed = Date.now() - dataT0; + if (dataElapsed > 50) { + console.warn(`[IPC SLOW] data handler took ${dataElapsed}ms chunkLen=${chunk.length}`); + } + }); + ipcSocket.on("error", (error) => { + debugLog("[Wo] IPC connection error:", error); + const hadConnected = ipcConnected; + ipcConnected = false; + dmabufSender = null; + if (hadConnected) { + scheduleIpcReconnect(); + return; + } + reject(error); + }); + ipcSocket.on("close", () => { + debugLog("[Wo] IPC connection closed"); + ipcConnected = false; + ipcSocket = null; + dmabufSender = null; + inFlightFrameSeqs.clear(); + compositorRxBuffer = Buffer.alloc(0); + scheduleIpcReconnect(); + }); + setTimeout(() => { + if (!ipcConnected) { + debugLog("[Wo] IPC connection timeout"); + reject(new Error("Failed to connect to compositor IPC socket")); + } + }, 5000); + }); +} +function setupIpcHandlers() { + ipcMain.handle("action", (_event, action, payload) => { + return sendActionToCompositor(action, payload); + }); + ipcMain.on("action-sync", (_event, action, payload) => { + sendActionToCompositor(action, payload); + }); + ipcMain.handle("wo:get-windows", async () => compositorWindows.length > 0 ? compositorWindows : clientWindows); + ipcMain.on("wo:keybind-action", (_event, action) => { + if (action === "shuffle") { + clientWindows = [...clientWindows].sort(() => Math.random() - 0.5); + } else if (action === "reverse") { + clientWindows = [...clientWindows].reverse(); + } + if (mainWindow) { + mainWindow.webContents.send("wo:windows", clientWindows); + } + }); + ipcMain.handle("wo:submit-damage-frame", (_event, payload) => { + if (!dmabufSender) { + return { ok: false, reason: "dmabuf-sender-not-ready" }; + } + if (inFlightFrameSeqs.size >= MAX_IN_FLIGHT_FRAMES) { + return { ok: true, skipped: true, reason: "backpressure" }; + } + if (!damageBuffer) { + damageBuffer = new DamageBuffer(payload.width, payload.height); + } + appUsedDamageHelper = true; + applyDamagePayload(damageBuffer, payload); + const seq = dmabufSender.send(damageBuffer); + inFlightFrameSeqs.add(seq.toString()); + return { ok: true, seq: seq.toString() }; + }); + ipcMain.on("wo:forward-keyboard", (_event, windowName, key, pressed, time) => { + if (ipcSocket && ipcConnected) { + const nameBuf = Buffer.from(windowName, "utf8"); + const msg = Buffer.alloc(4 + 4 + nameBuf.length + 12); + let off = 0; + msg.writeUInt32LE(MAGIC.FORWARD_KEYBOARD, off); + off += 4; + msg.writeUInt32LE(nameBuf.length, off); + off += 4; + nameBuf.copy(msg, off); + off += nameBuf.length; + msg.writeUInt32LE(key, off); + off += 4; + msg.writeUInt32LE(pressed ? 1 : 0, off); + off += 4; + msg.writeUInt32LE(time || 0, off); + ipcSocket.write(msg); + } + }); + ipcMain.on("wo:forward-relative-pointer", (_event, windowName, dx, dy) => { + if (ipcSocket && ipcConnected) { + const nameBuf = Buffer.from(windowName, "utf8"); + const msg = Buffer.alloc(4 + 4 + nameBuf.length + 16); + let off = 0; + msg.writeUInt32LE(MAGIC.FORWARD_RELATIVE_POINTER, off); + off += 4; + msg.writeUInt32LE(nameBuf.length, off); + off += 4; + nameBuf.copy(msg, off); + off += nameBuf.length; + msg.writeDoubleLE(dx, off); + off += 8; + msg.writeDoubleLE(dy, off); + ipcSocket.write(msg); + } + }); + const iconCache = new Map; + function resolveDesktopIconFast(iconName, fs2, path) { + if (iconCache.has(iconName)) + return iconCache.get(iconName) ?? null; + let result = null; + if (iconName.startsWith("/")) { + try { + if (fs2.existsSync(iconName)) { + const ext = path.extname(iconName).toLowerCase(); + const mime = ext === ".svg" ? "image/svg+xml" : "image/png"; + result = { type: "base64", data: fs2.readFileSync(iconName).toString("base64"), mimeType: mime }; + } + } catch {} + iconCache.set(iconName, result); + return result; + } + const quickPaths = [ + [`/usr/share/pixmaps/${iconName}.png`, "image/png"], + [`/usr/share/pixmaps/${iconName}.svg`, "image/svg+xml"], + [`/usr/share/icons/hicolor/48x48/apps/${iconName}.png`, "image/png"], + [`/usr/share/icons/hicolor/scalable/apps/${iconName}.svg`, "image/svg+xml"] + ]; + for (const [iconPath, mime] of quickPaths) { + try { + if (fs2.existsSync(iconPath)) { + result = { type: "base64", data: fs2.readFileSync(iconPath).toString("base64"), mimeType: mime }; + break; + } + } catch {} + } + iconCache.set(iconName, result); + return result; + } + ipcMain.handle("wo:syscall", async (_event, type, params) => { + try { + switch (type) { + case "list_applications": { + const path = await import("path"); + const fs2 = await import("fs"); + const apps = []; + const dirs = ["/usr/share/applications", `${process.env.HOME}/.local/share/applications`]; + const seen = new Set; + for (const dir of dirs) { + try { + if (!fs2.default.existsSync(dir)) + continue; + const files = fs2.default.readdirSync(dir).filter((f) => f.endsWith(".desktop")); + for (const file of files) { + const filePath = path.default.join(dir, file); + try { + const content = fs2.default.readFileSync(filePath, "utf-8"); + if (content.includes("NoDisplay=true")) + continue; + const nameMatch = content.match(/^Name=(.+)$/m); + const execMatch = content.match(/^Exec=(.+)$/m); + const iconMatch = content.match(/^Icon=(.+)$/m); + if (nameMatch && execMatch) { + const name2 = nameMatch[1].trim(); + if (!seen.has(name2)) { + seen.add(name2); + const exec = execMatch[1].trim().split(" ")[0].split("/").pop() || execMatch[1].trim(); + const app2 = { + name: name2, + command: exec + }; + if (iconMatch) { + const iconName = iconMatch[1].trim(); + const resolved = resolveDesktopIconFast(iconName, fs2.default, path.default); + if (resolved) { + app2.icon = resolved; + } else { + app2.icon = { type: "iconify", data: iconName }; + } + } + apps.push(app2); + } + } + } catch {} + } + } catch {} + } + return apps.length > 0 ? apps : [ + { name: "Terminal", icon: { type: "iconify", data: "mdi:console" }, command: "xterm" }, + { name: "Firefox", icon: { type: "iconify", data: "mdi:firefox" }, command: "firefox" }, + { name: "Files", icon: { type: "iconify", data: "mdi:folder" }, command: "nautilus" } + ]; + } + case "launch": + case "exec": { + const { spawn } = await import("child_process"); + const command = params.command || ""; + if (!command) { + return { ok: false, error: "No command specified" }; + } + try { + const child = spawn(command, { + detached: true, + stdio: "ignore", + shell: true + }); + child.unref(); + return { ok: true, pid: child.pid }; + } catch (error) { + return { ok: false, error: String(error) }; + } + } + case "listdir": { + const fs2 = await import("fs"); + const path = await import("path"); + const dirPath = params.path || process.env.HOME || "/home"; + try { + const entries = fs2.default.readdirSync(dirPath, { withFileTypes: true }); + return { + ok: true, + path: dirPath, + entries: entries.map((e) => ({ + name: e.name, + type: e.isDirectory() ? "dir" : "file" + })) + }; + } catch (error) { + return { ok: false, error: String(error) }; + } + } + case "read": { + const fs2 = await import("fs"); + const filePath = params.path; + if (!filePath) { + return { ok: false, error: "No file path specified" }; + } + try { + const content = fs2.default.readFileSync(filePath, "utf-8"); + return { ok: true, content, size: content.length }; + } catch (error) { + return { ok: false, error: String(error) }; + } + } + case "write": { + const fs2 = await import("fs"); + const filePath = params.path; + const content = params.content; + if (!filePath || content === undefined) { + return { ok: false, error: "Missing file path or content" }; + } + try { + fs2.default.writeFileSync(filePath, content); + return { ok: true, bytesWritten: content.length }; + } catch (error) { + return { ok: false, error: String(error) }; + } + } + case "shutdown": { + const { spawn } = await import("child_process"); + spawn("systemctl", ["poweroff"], { detached: true, stdio: "ignore" }).unref(); + return { ok: true }; + } + case "restart": { + const { spawn } = await import("child_process"); + spawn("systemctl", ["reboot"], { detached: true, stdio: "ignore" }).unref(); + return { ok: true }; + } + case "logout": { + const { spawn } = await import("child_process"); + const sessionId = process.env.XDG_SESSION_ID || ""; + if (sessionId) { + spawn("loginctl", ["terminate-session", sessionId], { detached: true, stdio: "ignore" }).unref(); + } else { + sendActionToCompositor("quit", { code: 0 }); + } + return { ok: true }; + } + case "lock": { + const { spawn } = await import("child_process"); + spawn("loginctl", ["lock-session"], { detached: true, stdio: "ignore" }).unref(); + return { ok: true }; + } + case "sleep": { + const { spawn } = await import("child_process"); + spawn("systemctl", ["suspend"], { detached: true, stdio: "ignore" }).unref(); + return { ok: true }; + } + case "notify": { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("wo:notification", { + id: params.id || `notify-${Date.now()}`, + title: params.title || "Notification", + body: params.body || "", + icon: params.icon, + timeout: params.timeout ?? 5000, + timestamp: Date.now() + }); + } + return { ok: true }; + } + case "tray_list": { + return getStatusNotifierTrayItems(); + } + case "tray_activate": { + const service = String(params.service || ""); + const objectPath = String(params.objectPath || ""); + if (!service || !objectPath) { + return { ok: false, error: "Missing service or objectPath" }; + } + const ok = activateStatusNotifierItem(service, objectPath); + return ok ? { ok: true } : { ok: false, error: "Activation failed" }; + } + case "tray_secondary_activate": { + const service = String(params.service || ""); + const objectPath = String(params.objectPath || ""); + if (!service || !objectPath) { + return { ok: false, error: "Missing service or objectPath" }; + } + const ok = secondaryActivateStatusNotifierItem(service, objectPath); + return ok ? { ok: true } : { ok: false, error: "Secondary activation failed" }; + } + case "tray_context_menu": { + const service = String(params.service || ""); + const objectPath = String(params.objectPath || ""); + const x = Number(params.x ?? 0); + const y = Number(params.y ?? 0); + if (!service || !objectPath) { + return { ok: false, error: "Missing service or objectPath" }; + } + const ok = openStatusNotifierContextMenu(service, objectPath, x, y); + return ok ? { ok: true } : { ok: false, error: "Context menu failed" }; + } + case "tray_menu_items": { + const service = String(params.service || ""); + const menuPath = String(params.menuPath || ""); + if (!service || !menuPath) { + return []; + } + return getDBusMenuLayout(service, menuPath); + } + case "tray_menu_event": { + const service = String(params.service || ""); + const menuPath = String(params.menuPath || ""); + const menuId = String(params.menuId || ""); + if (!service || !menuPath || !menuId) { + return { ok: false, error: "Missing parameters" }; + } + const ok = triggerDBusMenuEvent(service, menuPath, menuId); + return ok ? { ok: true } : { ok: false, error: "Menu event failed" }; + } + case "portal_respond": { + const requestId = String(params.requestId || ""); + if (!requestId) { + return { ok: false, error: "Missing portal requestId" }; + } + return respondToPortalRequest(requestId, { + allowed: Boolean(params.allowed), + type: typeof params.type === "string" ? params.type : undefined, + windowName: params.windowName + }); + } + default: + return { ok: false, error: `Unknown syscall: ${type}` }; + } + } catch (error) { + return { ok: false, error: `Syscall error: ${String(error)}` }; + } + }); +} +async function createWindow() { + debugLog("[Wo] createWindow starting"); + const preloadPath = joinPath(__dirname2, "preload.js"); + debugLog("[Wo] preloadPath=" + preloadPath); + try { + mainWindow = new BrowserWindow({ + width: CLIENT_MODE ? 1400 : width, + height: CLIENT_MODE ? 900 : height, + transparent: true, + backgroundColor: "#00000000", + frame: CLIENT_MODE, + webPreferences: { + sandbox: false, + contextIsolation: true, + preload: preloadPath, + nodeIntegration: false, + offscreen: !CLIENT_MODE + }, + show: CLIENT_MODE + }); + debugLog("[Wo] BrowserWindow created, CLIENT_MODE=" + CLIENT_MODE + " offscreen=" + !CLIENT_MODE); + setupIpcHandlers(); + debugLog("[Wo] setupIpcHandlers completed"); + mainWindow.webContents.send("wo:windows", clientWindows); + if (CLIENT_MODE) { + return; + } + debugLog("[Wo] Loading content URL: " + (url || "minimal fallback")); + try { + const pageUrl = url || "data:text/html,"; + const loadPromise = mainWindow.loadURL(pageUrl); + await Promise.race([ + loadPromise, + new Promise((r) => setTimeout(() => { + debugLog("[Wo] Load timeout, continuing"); + r(undefined); + }, 3000)) + ]); + debugLog("[Wo] Content loaded from: " + pageUrl); + } catch (e) { + debugLog("[Wo] Load failed:", String(e)); + } + debugLog("[Wo] Page initialized, setting up rendering"); + mainWindow.webContents.focus(); + if (!CLIENT_MODE) { + debugLog("[Wo] Setting up OSR rendering, FPS=" + (WINDOW_CONFIG.fps ?? 60)); + mainWindow.webContents.setFrameRate(WINDOW_CONFIG.fps ?? 60); + mainWindow.webContents.on("did-finish-load", () => { + mainWindow.webContents.setFrameRate(WINDOW_CONFIG.fps ?? 60); + }); + const frameIntervalMs = Math.round(1000 / (WINDOW_CONFIG.fps ?? 60)); + setInterval(() => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.invalidate(); + } + }, frameIntervalMs); + let skippedCount = 0; + mainWindow.webContents.on("paint", (_event, _dirty, image) => { + if (!dmabufSender || !ipcConnected) { + skippedCount++; + debugLog("[Wo] SKIPPED paint (dmabufSender=" + !!dmabufSender + " ipcConnected=" + ipcConnected + ")"); + return; + } + if (appUsedDamageHelper) { + return; + } + if (inFlightFrameSeqs.size >= MAX_IN_FLIGHT_FRAMES) { + skippedCount++; + if (skippedCount === 1 || skippedCount % 120 === 0) { + debugLog("[Wo] SKIPPED paint due to frame backpressure inFlight=" + inFlightFrameSeqs.size); + } + return; + } + const size = image.getSize(); + if (!damageBuffer) { + damageBuffer = new DamageBuffer(size.width, size.height); + } + const pixels = image.getBitmap(); + applyDamagePayload(damageBuffer, { + width: size.width, + height: size.height, + fullFrame: new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength) + }); + try { + const seq = dmabufSender.send(damageBuffer); + inFlightFrameSeqs.add(seq.toString()); + } catch (e) { + debugLog("[Wo] ERROR sending frame:", String(e)); + } + }); + } + if (process.env.WO_DEBUG === "1") { + mainWindow.webContents.openDevTools(); + } + debugLog("[Wo] createWindow completed successfully"); + } catch (error) { + debugLog("[Wo] createWindow error:", error); + throw error; + } + mainWindow.on("closed", () => { + mainWindow = null; + if (windowPositionUpdateTimer) { + clearInterval(windowPositionUpdateTimer); + windowPositionUpdateTimer = null; + } + }); + if (!CLIENT_MODE && ipcConnected) { + const sendPosition = () => { + if (mainWindow && !mainWindow.isDestroyed()) { + const bounds = mainWindow.getBounds(); + sendWindowPositionUpdate(bounds.x, bounds.y, bounds.width, bounds.height); + } + }; + sendPosition(); + windowPositionUpdateTimer = setInterval(sendPosition, 100); + mainWindow.on("move", sendPosition); + mainWindow.on("resize", sendPosition); + } +} +app.on("ready", async () => { + debugLog("[Wo] App ready, CLIENT_MODE=", CLIENT_MODE, "WO_STANDALONE=", process.env.WO_STANDALONE); + try { + startPortalUiBridge(); + if (!CLIENT_MODE && process.env.WO_STANDALONE !== "1") { + debugLog("[Wo] Will connect to compositor"); + await connectToCompositor(); + } + debugLog("[Wo] Creating window"); + await createWindow(); + debugLog("[Wo] Window created successfully"); + } catch (error) { + debugLog("[Wo] Failed to start app:", error); + app.quit(); + } +}); +app.on("window-all-closed", () => { + stopPortalUiBridge(); + if (process.platform !== "darwin") { + app.quit(); + } +}); +app.on("activate", () => { + if (mainWindow === null) { + createWindow().catch(console.error); + } +}); +app.on("before-quit", () => { + stopPortalUiBridge(); + closeShmFdCache(); +}); +export { + sendActionToCompositor, + connectToCompositor +}; diff --git a/electron/src/damage-buffer.ts b/electron/src/damage-buffer.ts index 3734393..92ef983 100644 --- a/electron/src/damage-buffer.ts +++ b/electron/src/damage-buffer.ts @@ -1,4 +1,5 @@ import { Socket } from 'net'; +import { writeSync } from 'fs'; import { MAGIC } from './protocol.js'; @@ -181,8 +182,14 @@ export class WoDmabufSender { plane.writeUInt32LE(imported.modifier_hi, 8); plane.writeUInt32LE(imported.modifier_lo, 12); - this.socket.write(header); - this.socket.write(plane); + // CRITICAL: We must use synchronous, unbuffered writes to the raw + // socket fd so that the header+plane bytes are in the kernel buffer + // BEFORE sendFd's sendmsg() call. Node's socket.write() is async + // and buffers in userspace — sendFd (a native sendmsg) would race + // ahead, delivering the SCM_RIGHTS ancillary data before the header + // bytes, corrupting the protocol framing on the compositor side. + const combined = Buffer.concat([header, plane]); + writeSync(socketFd, combined, 0, combined.length); this.native.sendFd(socketFd, imported.fd); const sentSeq = this.seq; diff --git a/src/electron.rs b/src/electron.rs index cf7bb86..720c9fe 100644 --- a/src/electron.rs +++ b/src/electron.rs @@ -832,7 +832,7 @@ fn handle_connection( match msg { ElectronMessage::Frame(frame) => { let seq = frame.seq; - debug!(window = %name, seq, "sending FRAME_ACK"); + info!(window = %name, seq, "received FRAME, sending ACK"); if let Err(e) = client.write_frame_ack(seq) { error!(window = %name, seq, "failed to send FRAME_ACK: {e:#}"); } diff --git a/src/main.rs b/src/main.rs index b5ec4e0..dbd5886 100644 --- a/src/main.rs +++ b/src/main.rs @@ -93,10 +93,13 @@ struct EventLoopData { electron_render_node: String, /// Total successful page flips (for startup diagnostics). total_flips: u64, - /// Hold the currently-displayed swapchain slot so the GBM BO is not - /// recycled while the CRTC is still scanning it out. Released when - /// the next frame is submitted. + /// Hold the buffer the CRTC is currently scanning out so its GBM BO is + /// not recycled. Promoted from `pending_slot` on VBlank. current_slot: Option>, + /// Hold the buffer submitted for the next page flip, waiting for VBlank + /// confirmation. Without this, the CRTC's active scanout buffer could be + /// freed prematurely, causing every-other-frame flicker. + pending_slot: Option>, } const MAX_CONSECUTIVE_FLIP_FAILURES: u32 = 5; @@ -335,6 +338,7 @@ fn render_frame(data: &mut EventLoopData) { ); data.vblank_pending = false; data.vblank_since = None; + data.current_slot = data.pending_slot.take(); } else { return; } @@ -544,6 +548,21 @@ fn render_frame(data: &mut EventLoopData) { ); } + // Finalize the GL frame — flushes all rendering commands to the + // framebuffer. Without this, the GBM BO may contain stale or + // incomplete pixel data, causing flicker. + match frame.finish() { + Ok(_) => {} + Err(e) => { + warn!("Failed to finish frame: {e}"); + if is_egl_context_or_surface_lost(&e.to_string()) { + error!("EGL context/surface lost while finishing frame"); + context_lost = true; + return false; + } + } + } + true })(); @@ -617,9 +636,9 @@ fn render_frame(data: &mut EventLoopData) { data.vblank_pending = true; data.vblank_since = Some(Instant::now()); data.swapchain.submitted(&slot); - // Hold the slot so its GBM BO is not recycled while the CRTC - // scans it out. The previous slot (if any) is released here. - data.current_slot = Some(slot); + // Hold the slot as pending until VBlank confirms the flip. + // current_slot (the active scanout buffer) stays alive until then. + data.pending_slot = Some(slot); data.total_flips += 1; if data.total_flips == 1 { info!("First successful page flip (commit_pending={})", commit_pending); @@ -670,7 +689,7 @@ fn render_frame(data: &mut EventLoopData) { data.vblank_pending = true; data.vblank_since = Some(Instant::now()); data.swapchain.submitted(&slot); - data.current_slot = Some(slot); + data.pending_slot = Some(slot); data.total_flips += 1; } } @@ -1188,6 +1207,7 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { electron_render_node: electron_render_node, total_flips: 0, current_slot: None, + pending_slot: None, }; let signal_for_handler = loop_signal.clone(); @@ -1205,14 +1225,17 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { if let DrmEvent::VBlank(_) = event { data.vblank_pending = false; data.vblank_since = None; + data.current_slot = data.pending_slot.take(); } trace!("Ignoring DRM event while session is inactive"); return; } if let DrmEvent::VBlank(_) = event { - // The screen just refreshed! We are clear to render the next frame. + // The flip is complete: the pending buffer is now being scanned. + // Promote it to current (releasing the old scanout buffer). data.vblank_pending = false; data.vblank_since = None; + data.current_slot = data.pending_slot.take(); render_frame(data); } })