3835 lines
145 KiB
JavaScript
3835 lines
145 KiB
JavaScript
// src/main.ts
|
|
import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, safeStorage } from "electron";
|
|
import { createConnection, createServer } from "net";
|
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
import { dirname, resolve as resolvePath, join as joinPath } from "path";
|
|
import { createHash as createHash2, randomUUID } from "crypto";
|
|
import fs from "fs";
|
|
import { createRequire } from "module";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
// src/xdg.ts
|
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { basename, delimiter, extname, join, resolve } from "node:path";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
function getXdgDirectories(env = process.env) {
|
|
const home = env.HOME || "/";
|
|
const configHome = env.XDG_CONFIG_HOME || join(home, ".config");
|
|
const dataHome = env.XDG_DATA_HOME || join(home, ".local", "share");
|
|
const dataDirs = splitDirs(env.XDG_DATA_DIRS || "/usr/local/share:/usr/share");
|
|
const configDirs = splitDirs(env.XDG_CONFIG_DIRS || "/etc/xdg");
|
|
const userDirsFile = join(configHome, "user-dirs.dirs");
|
|
const userDirs = parseUserDirs(userDirsFile, home);
|
|
const userDir = (key, fallback) => userDirs[key] || join(home, fallback);
|
|
return {
|
|
home,
|
|
configHome,
|
|
configDirs,
|
|
dataHome,
|
|
dataDirs,
|
|
cacheHome: env.XDG_CACHE_HOME || join(home, ".cache"),
|
|
stateHome: env.XDG_STATE_HOME || join(home, ".local", "state"),
|
|
runtimeDir: env.XDG_RUNTIME_DIR || null,
|
|
desktop: userDir("XDG_DESKTOP_DIR", "Desktop"),
|
|
documents: userDir("XDG_DOCUMENTS_DIR", "Documents"),
|
|
downloads: userDir("XDG_DOWNLOAD_DIR", "Downloads"),
|
|
music: userDir("XDG_MUSIC_DIR", "Music"),
|
|
pictures: userDir("XDG_PICTURES_DIR", "Pictures"),
|
|
publicShare: userDir("XDG_PUBLICSHARE_DIR", "Public"),
|
|
templates: userDir("XDG_TEMPLATES_DIR", "Templates"),
|
|
videos: userDir("XDG_VIDEOS_DIR", "Videos")
|
|
};
|
|
}
|
|
function splitDirs(value) {
|
|
return [...new Set(value.split(delimiter).filter(Boolean).map((dir) => resolve(dir)))];
|
|
}
|
|
function parseUserDirs(file, home) {
|
|
if (!existsSync(file))
|
|
return {};
|
|
const result = {};
|
|
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
const match = line.match(/^\s*(XDG_[A-Z]+_DIR)="((?:\\.|[^"])*)"\s*$/);
|
|
if (!match)
|
|
continue;
|
|
const value = match[2].replace(/\\"/g, '"').replace(/\\\\/g, "\\").replace(/^\$HOME(?=\/|$)/, home);
|
|
if (value.startsWith("/"))
|
|
result[match[1]] = resolve(value);
|
|
}
|
|
return result;
|
|
}
|
|
function decodeDesktopValue(value) {
|
|
return value.replace(/\\([snrt\\])/g, (_match, code) => ({
|
|
s: " ",
|
|
n: `
|
|
`,
|
|
r: "\r",
|
|
t: "\t",
|
|
"\\": "\\"
|
|
})[code] ?? code);
|
|
}
|
|
function desktopKeyValues(content, locale) {
|
|
const values = new Map;
|
|
let inEntry = false;
|
|
const localized = new Map;
|
|
const language = locale.split(/[.@]/)[0];
|
|
for (const line of content.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith("[")) {
|
|
inEntry = trimmed === "[Desktop Entry]";
|
|
continue;
|
|
}
|
|
if (!inEntry || !trimmed || trimmed.startsWith("#"))
|
|
continue;
|
|
const splitAt = trimmed.indexOf("=");
|
|
if (splitAt < 1)
|
|
continue;
|
|
const key = trimmed.slice(0, splitAt);
|
|
const value = decodeDesktopValue(trimmed.slice(splitAt + 1));
|
|
const localizedKey = key.match(/^Name\[(.+)\]$/);
|
|
if (localizedKey)
|
|
localized.set(localizedKey[1], value);
|
|
else
|
|
values.set(key, value);
|
|
}
|
|
if (language && localized.has(language))
|
|
values.set("Name", localized.get(language));
|
|
return values;
|
|
}
|
|
function availableCommand(command) {
|
|
if (!command)
|
|
return false;
|
|
if (command.includes("/"))
|
|
return existsSync(command);
|
|
const result = spawnSync("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "wo", command], {
|
|
stdio: "ignore",
|
|
env: process.env
|
|
});
|
|
return result.status === 0;
|
|
}
|
|
function resolveDesktopIcon(name, options = {}) {
|
|
const xdg = getXdgDirectories(options.env);
|
|
const size = Math.max(16, options.size ?? 48);
|
|
const theme = options.theme || options.env?.XDG_ICON_THEME || process.env.XDG_ICON_THEME || "hicolor";
|
|
const suffixes = extname(name) ? [""] : [".png", ".svg"];
|
|
const candidates = [];
|
|
if (name.startsWith("/"))
|
|
candidates.push(name);
|
|
for (const suffix of suffixes) {
|
|
for (const root of [xdg.dataHome, ...xdg.dataDirs]) {
|
|
for (const iconTheme of [theme, "hicolor"]) {
|
|
const themeRoot = join(root, "icons", iconTheme);
|
|
for (const directory of iconSizeDirectories(themeRoot, size)) {
|
|
candidates.push(join(themeRoot, directory, "apps", `${name}${suffix}`));
|
|
}
|
|
candidates.push(join(themeRoot, "apps", `${name}${suffix}`));
|
|
}
|
|
candidates.push(join(root, "pixmaps", `${name}${suffix}`));
|
|
}
|
|
}
|
|
const file = candidates.find((candidate) => existsSync(candidate));
|
|
if (!file)
|
|
return;
|
|
const extension = extname(file).toLowerCase();
|
|
const mimeType = extension === ".svg" ? "image/svg+xml" : "image/png";
|
|
try {
|
|
return { type: "base64", data: readFileSync(file).toString("base64"), mimeType };
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
function iconSizeDirectories(themeRoot, target) {
|
|
try {
|
|
return readdirSync(themeRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => /^(?:scalable|\d+x\d+(?:@\d+(?:\.\d+)?x)?)$/.test(name)).sort((a, b) => iconDirectoryDistance(a, target) - iconDirectoryDistance(b, target));
|
|
} catch {
|
|
return ["scalable", `${target}x${target}`, "48x48", "32x32", "24x24", "16x16"];
|
|
}
|
|
}
|
|
function iconDirectoryDistance(directory, target) {
|
|
if (directory === "scalable")
|
|
return 0;
|
|
const match = directory.match(/^(\d+)x\d+/);
|
|
return match ? Math.abs(Number(match[1]) - target) + 1 : Number.MAX_SAFE_INTEGER;
|
|
}
|
|
function listDesktopApplications(options = {}) {
|
|
const env = options.env || process.env;
|
|
const xdg = getXdgDirectories(env);
|
|
const locale = options.locale || env.LC_ALL || env.LC_MESSAGES || env.LANG || "C";
|
|
const dirs = [join(xdg.dataHome, "applications"), ...xdg.dataDirs.map((dir) => join(dir, "applications"))];
|
|
const seen = new Set;
|
|
const apps = [];
|
|
for (const dir of dirs) {
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(dir).filter((entry) => entry.endsWith(".desktop")).sort();
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
const file = join(dir, entry);
|
|
let keys;
|
|
try {
|
|
const content = readFileSync(file, "utf8");
|
|
if (content.length > 512000)
|
|
continue;
|
|
keys = desktopKeyValues(content, locale);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const id = entry;
|
|
if (seen.has(id) || keys.get("Type") !== "Application")
|
|
continue;
|
|
seen.add(id);
|
|
if (keys.get("Hidden") === "true" || keys.get("NoDisplay") === "true")
|
|
continue;
|
|
const onlyShowIn = (keys.get("OnlyShowIn") || "").split(";").filter(Boolean);
|
|
const notShowIn = (keys.get("NotShowIn") || "").split(";").filter(Boolean);
|
|
const desktops = (env.XDG_CURRENT_DESKTOP || "").split(":").filter(Boolean);
|
|
if (onlyShowIn.length && !onlyShowIn.some((desktop) => desktops.includes(desktop)))
|
|
continue;
|
|
if (notShowIn.some((desktop) => desktops.includes(desktop)))
|
|
continue;
|
|
const execLine = keys.get("Exec") || "";
|
|
const command = parseDesktopExec(execLine).argv[0] || "";
|
|
if (!command || keys.has("TryExec") && !availableCommand(keys.get("TryExec")))
|
|
continue;
|
|
const name = keys.get("Name") || basename(entry, ".desktop");
|
|
const iconName = keys.get("Icon");
|
|
const icon = iconName ? resolveDesktopIcon(iconName, { env }) : undefined;
|
|
apps.push({
|
|
name,
|
|
command,
|
|
desktopId: id,
|
|
desktopFile: file,
|
|
...icon ? { icon } : {},
|
|
...keys.has("GenericName") ? { genericName: keys.get("GenericName") } : {},
|
|
...keys.has("Comment") ? { comment: keys.get("Comment") } : {},
|
|
...keys.has("Categories") ? { categories: keys.get("Categories").split(";").filter(Boolean) } : {},
|
|
...keys.has("Keywords") ? { keywords: keys.get("Keywords").split(";").filter(Boolean) } : {},
|
|
...keys.has("StartupWMClass") ? { startupWMClass: keys.get("StartupWMClass") } : {},
|
|
...keys.has("Terminal") ? { terminal: keys.get("Terminal") === "true" } : {},
|
|
...keys.has("StartupNotify") ? { startupNotify: keys.get("StartupNotify") === "true" } : {}
|
|
});
|
|
}
|
|
}
|
|
return apps;
|
|
}
|
|
async function launchDesktopApplication(desktopId, files = []) {
|
|
const app = listDesktopApplications().find((entry) => entry.desktopId === desktopId || entry.name === desktopId);
|
|
if (!app)
|
|
return { ok: false, error: `Desktop application not found: ${desktopId}` };
|
|
const keys = desktopKeyValues(readFileSync(app.desktopFile, "utf8"), process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || "C");
|
|
const parsed = parseDesktopExec(keys.get("Exec") || "", { ...app, iconName: keys.get("Icon") }, files);
|
|
const [command, ...args] = parsed.argv;
|
|
if (!command)
|
|
return { ok: false, error: `No executable in desktop entry: ${desktopId}` };
|
|
return new Promise((resolveResult) => {
|
|
try {
|
|
const child = spawn(command, args, { detached: true, stdio: "ignore", env: process.env });
|
|
child.once("error", (error) => resolveResult({ ok: false, error: error.message }));
|
|
child.once("spawn", () => {
|
|
child.unref();
|
|
resolveResult({ ok: true, pid: child.pid });
|
|
});
|
|
} catch (error) {
|
|
resolveResult({ ok: false, error: String(error) });
|
|
}
|
|
});
|
|
}
|
|
function parseDesktopExec(exec, app, files = []) {
|
|
const tokens = exec.match(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s]+/g) || [];
|
|
const argv = [];
|
|
for (const token of tokens) {
|
|
const unquoted = token.replace(/^['"]|['"]$/g, "").replace(/\\(["'\\`$])/g, "$1");
|
|
if (unquoted === "%i") {
|
|
if (app?.iconName)
|
|
argv.push("--icon", app.iconName);
|
|
continue;
|
|
}
|
|
if (unquoted === "%F" || unquoted === "%U") {
|
|
argv.push(...files);
|
|
continue;
|
|
}
|
|
const value = unquoted.replace(/%%|%[fFuUdDnNickvm]/g, (code) => {
|
|
if (code === "%%")
|
|
return "%";
|
|
if (code === "%c")
|
|
return app?.name || "";
|
|
if (code === "%k")
|
|
return app?.desktopFile || "";
|
|
if (code === "%f" || code === "%u")
|
|
return files[0] || "";
|
|
return "";
|
|
});
|
|
if (value)
|
|
argv.push(value);
|
|
}
|
|
return { argv };
|
|
}
|
|
|
|
// src/wo-capabilities.ts
|
|
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
var WO_CAPABILITIES = [
|
|
"window-management",
|
|
"overlay-rendering",
|
|
"desktop-applications",
|
|
"xdg",
|
|
"dbus",
|
|
"portals",
|
|
"filesystem",
|
|
"process-launch",
|
|
"system-control",
|
|
"notifications",
|
|
"audio-capture",
|
|
"settings"
|
|
];
|
|
var MAX_INDEX_BYTES = 4 * 1024 * 1024;
|
|
var MAX_ASSET_BYTES = 16 * 1024 * 1024;
|
|
function sha256(bytes) {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
function isHash(value) {
|
|
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
}
|
|
function validateWoManifest(value, indexHash, pageUrl) {
|
|
if (!value || typeof value !== "object")
|
|
return null;
|
|
const m = value;
|
|
let page;
|
|
try {
|
|
page = new URL(pageUrl);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (m.schemaVersion !== 1 || typeof m.appId !== "string" || !/^[a-zA-Z0-9._-]{1,128}$/.test(m.appId))
|
|
return null;
|
|
if (m.indexSha256 !== indexHash || m.origin !== undefined && m.origin !== page.origin)
|
|
return null;
|
|
if (!Array.isArray(m.capabilities) || !m.capabilities.every((cap) => WO_CAPABILITIES.includes(cap)))
|
|
return null;
|
|
if (!m.componentsCss || typeof m.componentsCss.url !== "string" || !isHash(m.componentsCss.sha256))
|
|
return null;
|
|
if (!Array.isArray(m.scripts) || m.scripts.length > 256)
|
|
return null;
|
|
if (m.styles !== undefined && (!Array.isArray(m.styles) || m.styles.length > 256))
|
|
return null;
|
|
const trustedPath = (path) => {
|
|
try {
|
|
const resolved = new URL(path, page);
|
|
return resolved.origin === page.origin && (resolved.protocol === "https:" || resolved.protocol === "http:" || resolved.protocol === "file:");
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
if (!trustedPath(m.componentsCss.url))
|
|
return null;
|
|
const seen = new Set;
|
|
for (const script of m.scripts) {
|
|
if (!script || typeof script.url !== "string" || !isHash(script.sha256) || !trustedPath(script.url))
|
|
return null;
|
|
const absolute = new URL(script.url, page).href;
|
|
if (seen.has(absolute))
|
|
return null;
|
|
seen.add(absolute);
|
|
}
|
|
for (const style of m.styles ?? []) {
|
|
if (!style || typeof style.url !== "string" || !isHash(style.sha256) || !trustedPath(style.url))
|
|
return null;
|
|
const absolute = new URL(style.url, page).href;
|
|
if (seen.has(absolute))
|
|
return null;
|
|
seen.add(absolute);
|
|
}
|
|
return m;
|
|
}
|
|
async function readUrl(url, maxBytes, fetcher) {
|
|
try {
|
|
let bytes;
|
|
if (new URL(url).protocol === "file:") {
|
|
bytes = await readFile(fileURLToPath(url));
|
|
} else {
|
|
const response = await fetcher(url);
|
|
if (!response.ok)
|
|
return null;
|
|
bytes = new Uint8Array(await response.arrayBuffer());
|
|
}
|
|
return bytes.byteLength <= maxBytes ? bytes : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
var assetCache = new Map;
|
|
async function loadAsset(url, hash, maxBytes, fetcher) {
|
|
const cacheKey = `${url}#${hash}`;
|
|
const cached = assetCache.get(cacheKey);
|
|
if (cached)
|
|
return cached;
|
|
const bytes = await readUrl(url, maxBytes, fetcher);
|
|
if (!bytes || sha256(bytes) !== hash)
|
|
return null;
|
|
assetCache.set(cacheKey, bytes);
|
|
return bytes;
|
|
}
|
|
async function prefetchWoSiteAssets(pageUrl, fetcher = async (url) => {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.ok)
|
|
return response;
|
|
const parsed = new URL(url);
|
|
if (parsed.hostname !== "localhost" || parsed.protocol !== "http:")
|
|
return response;
|
|
parsed.hostname = "127.0.0.1";
|
|
return await fetch(parsed.href);
|
|
} catch (error) {
|
|
const parsed = new URL(url);
|
|
if (parsed.hostname !== "localhost" || parsed.protocol !== "http:")
|
|
throw error;
|
|
parsed.hostname = "127.0.0.1";
|
|
return await fetch(parsed.href);
|
|
}
|
|
}) {
|
|
const empty = (trustError, indexSha256 = "") => ({
|
|
pageUrl,
|
|
appId: "website",
|
|
capabilities: [],
|
|
styles: "",
|
|
stylesUrl: "",
|
|
stylesHash: "",
|
|
styleBodies: [],
|
|
scripts: [],
|
|
scriptBodies: [],
|
|
indexSha256,
|
|
indexBytes: new Uint8Array,
|
|
stylesBytes: new Uint8Array,
|
|
trusted: false,
|
|
trustError
|
|
});
|
|
let page;
|
|
try {
|
|
page = new URL(pageUrl);
|
|
} catch {
|
|
return empty("invalid page URL");
|
|
}
|
|
if (!["http:", "https:", "file:"].includes(page.protocol))
|
|
return empty(`unsupported page protocol: ${page.protocol}`);
|
|
const index = await readUrl(page.href, MAX_INDEX_BYTES, fetcher);
|
|
if (!index)
|
|
return empty(`could not read page: ${page.href}`);
|
|
const indexSha256 = sha256(index);
|
|
const manifestUrl = new URL("_wo.capabilities.json", new URL(".", page)).href;
|
|
const manifestBytes = await readUrl(manifestUrl, 256 * 1024, fetcher);
|
|
if (!manifestBytes)
|
|
return empty(`could not read capability manifest: ${manifestUrl}`, indexSha256);
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(new TextDecoder().decode(manifestBytes));
|
|
} catch {
|
|
return empty("capability manifest is not valid JSON", indexSha256);
|
|
}
|
|
const manifest = validateWoManifest(parsed, indexSha256, page.href);
|
|
if (!manifest)
|
|
return empty("capability manifest does not match this page or contains invalid fields", indexSha256);
|
|
const cssUrl = new URL(manifest.componentsCss.url, page).href;
|
|
const css = await loadAsset(cssUrl, manifest.componentsCss.sha256, 1024 * 1024, fetcher);
|
|
if (!css)
|
|
return empty(`component stylesheet failed SHA-256 verification: ${cssUrl}`, indexSha256);
|
|
const scripts = [];
|
|
const scriptBodies = [];
|
|
for (const script of manifest.scripts) {
|
|
const scriptUrl = new URL(script.url, page).href;
|
|
const bytes = await loadAsset(scriptUrl, script.sha256, MAX_ASSET_BYTES, fetcher);
|
|
if (!bytes)
|
|
return empty(`script failed SHA-256 verification: ${scriptUrl}`, indexSha256);
|
|
scripts.push(scriptUrl);
|
|
scriptBodies.push({ url: scriptUrl, hash: script.sha256, bytes });
|
|
}
|
|
const styleBodies = [];
|
|
for (const style of manifest.styles ?? []) {
|
|
const styleUrl = new URL(style.url, page).href;
|
|
const bytes = await loadAsset(styleUrl, style.sha256, MAX_ASSET_BYTES, fetcher);
|
|
if (!bytes)
|
|
return empty(`stylesheet failed SHA-256 verification: ${styleUrl}`, indexSha256);
|
|
styleBodies.push({ url: styleUrl, hash: style.sha256, bytes });
|
|
}
|
|
return {
|
|
pageUrl: page.href,
|
|
appId: manifest.appId,
|
|
capabilities: [...new Set(manifest.capabilities)],
|
|
styles: new TextDecoder().decode(css),
|
|
stylesUrl: cssUrl,
|
|
stylesHash: manifest.componentsCss.sha256,
|
|
styleBodies,
|
|
scripts,
|
|
scriptBodies,
|
|
indexSha256,
|
|
indexBytes: index,
|
|
stylesBytes: css,
|
|
trusted: true
|
|
};
|
|
}
|
|
|
|
// src/frame-pacing.ts
|
|
function chooseFrameRate(configuredFps, displayFps) {
|
|
const display = Number.isFinite(displayFps) && (displayFps ?? 0) > 0 ? Math.round(displayFps) : 60;
|
|
const configured = Number.isFinite(configuredFps) && (configuredFps ?? 0) > 0 ? Math.round(configuredFps) : display;
|
|
return Math.max(1, Math.min(configured, display));
|
|
}
|
|
function frameIntervalMs(fps) {
|
|
return 1000 / Math.max(1, fps);
|
|
}
|
|
|
|
// 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,
|
|
FORWARD_POINTER_BUTTON: 1464815682,
|
|
FORWARD_POINTER_SCROLL: 1464815699,
|
|
FORWARD_POINTER_LEAVE: 1464815694,
|
|
POINTER_LOCK_REQUEST: 1464815692,
|
|
ENV_UPDATE: 1464812885,
|
|
SCREENCOPY_EVENT: 1464816453,
|
|
WINDOW_SIZE: 1464816218,
|
|
SITE_HASHES: 1464816456
|
|
};
|
|
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) {
|
|
this.frame.set(new Uint8Array(frame.buffer, frame.byteOffset, 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;
|
|
this.frame.set(new Uint8Array(frame.buffer, frame.byteOffset + srcStart, rowBytes), dstStart);
|
|
}
|
|
}
|
|
applyPatch(patch) {
|
|
const rect = clampRect(patch.rect, this.width, this.height);
|
|
if (rect.width <= 0 || rect.height <= 0) {
|
|
return;
|
|
}
|
|
const patchStride = patch.stride ?? patch.rect.width * 4;
|
|
const sourceX = rect.x - patch.rect.x;
|
|
const sourceY = rect.y - patch.rect.y;
|
|
const dstStartX = rect.x * 4;
|
|
const rowBytes = rect.width * 4;
|
|
for (let row = 0;row < rect.height; row += 1) {
|
|
const srcStart = (sourceY + row) * patchStride + sourceX * 4;
|
|
const dstStart = (rect.y + row) * this.stride + dstStartX;
|
|
this.frame.set(new Uint8Array(patch.rgba.buffer, patch.rgba.byteOffset + srcStart, rowBytes), dstStart);
|
|
}
|
|
}
|
|
applyPatches(patches) {
|
|
for (const patch of patches) {
|
|
this.applyPatch(patch);
|
|
}
|
|
}
|
|
}
|
|
|
|
class WoDmabufSender {
|
|
socket;
|
|
native;
|
|
seq = 1n;
|
|
wireBuf;
|
|
sharedWireBuf;
|
|
nameBuf;
|
|
nameLen;
|
|
seqOffset;
|
|
widthOffset;
|
|
heightOffset;
|
|
pendingReleases = new Map;
|
|
constructor(socket, windowName, native) {
|
|
this.socket = socket;
|
|
this.native = native;
|
|
const nameBuf = Buffer.from(windowName, "utf8");
|
|
this.nameBuf = nameBuf;
|
|
this.nameLen = nameBuf.length;
|
|
const totalLen = 32 + this.nameLen + 16;
|
|
this.wireBuf = Buffer.alloc(totalLen);
|
|
this.sharedWireBuf = Buffer.alloc(32 + this.nameLen + 4 * 16);
|
|
let off = 0;
|
|
this.wireBuf.writeUInt32LE(MAGIC.FRAME, off);
|
|
off += 4;
|
|
this.wireBuf.writeUInt32LE(this.nameLen, off);
|
|
off += 4;
|
|
nameBuf.copy(this.wireBuf, off);
|
|
off += this.nameLen;
|
|
this.seqOffset = off;
|
|
off += 8;
|
|
this.widthOffset = off;
|
|
off += 4;
|
|
this.heightOffset = off;
|
|
off += 4;
|
|
this.wireBuf.writeUInt32LE(ARGB8888, off);
|
|
off += 4;
|
|
this.wireBuf.writeUInt32LE(1, off);
|
|
}
|
|
send(buffer) {
|
|
if (buffer.getWidth() <= 0 || buffer.getHeight() <= 0 || buffer.getStride() < buffer.getWidth() * 4 || buffer.getFrameBuffer().byteLength < buffer.getStride() * buffer.getHeight()) {
|
|
throw new RangeError("Cannot send an incomplete or zero-sized DMA-BUF frame");
|
|
}
|
|
const imported = this.native.importRgba(buffer.getFrameBuffer(), buffer.getWidth(), buffer.getHeight(), buffer.getStride());
|
|
let sent = false;
|
|
const sentSeq = this.seq;
|
|
try {
|
|
const socketFd = getSocketFd(this.socket);
|
|
this.wireBuf.writeBigUInt64LE(this.seq, this.seqOffset);
|
|
this.wireBuf.writeUInt32LE(buffer.getWidth(), this.widthOffset);
|
|
this.wireBuf.writeUInt32LE(buffer.getHeight(), this.heightOffset);
|
|
const planeOff = this.wireBuf.length - 16;
|
|
this.wireBuf.writeUInt32LE(imported.offset, planeOff);
|
|
this.wireBuf.writeUInt32LE(imported.stride, planeOff + 4);
|
|
this.wireBuf.writeUInt32LE(imported.modifier_hi, planeOff + 8);
|
|
this.wireBuf.writeUInt32LE(imported.modifier_lo, planeOff + 12);
|
|
writeSync(socketFd, this.wireBuf, 0, this.wireBuf.length);
|
|
this.native.sendFd(socketFd, imported.fd);
|
|
this.pendingReleases.set(sentSeq.toString(), () => this.native.releaseBuffer(imported.token));
|
|
this.seq += 1n;
|
|
sent = true;
|
|
return sentSeq;
|
|
} finally {
|
|
if (!sent)
|
|
this.native.releaseBuffer(imported.token);
|
|
}
|
|
}
|
|
sendSharedTexture(texture, release) {
|
|
const pixmap = texture.handle?.nativePixmap;
|
|
const planes = pixmap?.planes;
|
|
if (!planes || planes.length < 1 || planes.length > 4) {
|
|
throw new Error("shared texture has no supported DMA-BUF planes");
|
|
}
|
|
const fourcc = texture.pixelFormat === "bgra" ? fourccCode("A", "R", "2", "4") : texture.pixelFormat === "rgba" ? fourccCode("A", "B", "2", "4") : texture.pixelFormat === "nv12" ? fourccCode("N", "V", "1", "2") : 0;
|
|
if (fourcc === 0)
|
|
throw new Error(`unsupported shared texture pixel format: ${texture.pixelFormat}`);
|
|
const width = texture.codedSize.width;
|
|
const height = texture.codedSize.height;
|
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
throw new Error("shared texture has invalid coded dimensions");
|
|
}
|
|
const headerLength = 32 + this.nameLen + planes.length * 16;
|
|
let off = 0;
|
|
this.sharedWireBuf.writeUInt32LE(MAGIC.FRAME, off);
|
|
off += 4;
|
|
this.sharedWireBuf.writeUInt32LE(this.nameLen, off);
|
|
off += 4;
|
|
this.nameBuf.copy(this.sharedWireBuf, off);
|
|
off += this.nameLen;
|
|
this.sharedWireBuf.writeBigUInt64LE(this.seq, off);
|
|
off += 8;
|
|
this.sharedWireBuf.writeUInt32LE(width, off);
|
|
off += 4;
|
|
this.sharedWireBuf.writeUInt32LE(height, off);
|
|
off += 4;
|
|
this.sharedWireBuf.writeUInt32LE(fourcc, off);
|
|
off += 4;
|
|
this.sharedWireBuf.writeUInt32LE(planes.length, off);
|
|
off += 4;
|
|
const modifier = BigInt(pixmap.modifier || "0");
|
|
for (const plane of planes) {
|
|
this.sharedWireBuf.writeUInt32LE(plane.offset >>> 0, off);
|
|
off += 4;
|
|
this.sharedWireBuf.writeUInt32LE(plane.stride >>> 0, off);
|
|
off += 4;
|
|
this.sharedWireBuf.writeUInt32LE(Number(modifier >> 32n & 0xffffffffn), off);
|
|
off += 4;
|
|
this.sharedWireBuf.writeUInt32LE(Number(modifier & 0xffffffffn), off);
|
|
off += 4;
|
|
}
|
|
const socketFd = getSocketFd(this.socket);
|
|
for (const plane of planes) {
|
|
if (!Number.isInteger(plane.fd) || plane.fd < 0 || !Number.isInteger(plane.stride) || plane.stride <= 0 || !Number.isInteger(plane.offset) || plane.offset < 0) {
|
|
throw new Error("shared texture has invalid DMA-BUF plane metadata");
|
|
}
|
|
}
|
|
writeAllSync(socketFd, this.sharedWireBuf, headerLength);
|
|
for (const plane of planes)
|
|
this.native.sendFd(socketFd, plane.fd);
|
|
const sentSeq = this.seq;
|
|
this.pendingReleases.set(sentSeq.toString(), release);
|
|
this.seq += 1n;
|
|
return sentSeq;
|
|
}
|
|
acknowledge(seq) {
|
|
const key = seq.toString();
|
|
const release = this.pendingReleases.get(key);
|
|
if (!release)
|
|
return;
|
|
this.pendingReleases.delete(key);
|
|
release();
|
|
}
|
|
releaseAll() {
|
|
for (const release of this.pendingReleases.values())
|
|
release();
|
|
this.pendingReleases.clear();
|
|
}
|
|
}
|
|
function writeAllSync(fd, buffer, length) {
|
|
let offset = 0;
|
|
while (offset < length) {
|
|
const written = writeSync(fd, buffer, offset, length - offset);
|
|
if (written <= 0)
|
|
throw new Error("IPC socket closed while sending a DMA-BUF frame");
|
|
offset += written;
|
|
}
|
|
}
|
|
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 = fileURLToPath2(import.meta.url);
|
|
var __dirname2 = dirname(__filename2);
|
|
var require2 = createRequire(import.meta.url);
|
|
protocol.registerSchemesAsPrivileged([{
|
|
scheme: "wo-cache",
|
|
privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, codeCache: true }
|
|
}]);
|
|
var verifiedScriptCache = new Map;
|
|
var approvedResourceRequests = new Map;
|
|
var approvedAllResourcePages = new Set;
|
|
var resourceConsentQueue = Promise.resolve();
|
|
var MAX_APPROVED_RESOURCE_BYTES = 16 * 1024 * 1024;
|
|
function setupWoCacheProtocol() {
|
|
protocol.handle("wo-cache", (request) => {
|
|
const url = new URL(request.url);
|
|
const [, hash = "", kind = "script.js", encodedSourceUrl = ""] = url.pathname.split("/");
|
|
const bytes = verifiedScriptCache.get(hash);
|
|
if (!bytes) {
|
|
debugLog(`[Wo] Verified-script cache miss: ${url.pathname}`);
|
|
return new Response("Missing verified Wo script", { status: 404 });
|
|
}
|
|
debugLog(`[Wo] Serving verified script ${hash.slice(0, 12)} (${bytes.byteLength} bytes)`);
|
|
const isStylesheet = kind === "style.css";
|
|
let responseBytes = bytes;
|
|
if (encodedSourceUrl) {
|
|
try {
|
|
const sourceUrl = Buffer.from(encodedSourceUrl, "base64url").toString("utf8");
|
|
responseBytes = isStylesheet ? rewriteStylesheetUrls(bytes, sourceUrl) : rewriteModuleImports(bytes, sourceUrl);
|
|
} catch (error) {
|
|
debugLog(`[Wo] Could not rewrite cached module imports for ${hash.slice(0, 12)}: ${String(error)}`);
|
|
}
|
|
}
|
|
return new Response(new Uint8Array(responseBytes), {
|
|
headers: {
|
|
"Content-Type": isStylesheet ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8",
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Cross-Origin-Resource-Policy": "cross-origin",
|
|
"Cache-Control": "public, max-age=31536000, immutable"
|
|
}
|
|
});
|
|
});
|
|
}
|
|
function rewriteStylesheetUrls(bytes, sourceUrl) {
|
|
const css = bytes.toString("utf8");
|
|
const rewritten = css.replace(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/gi, (whole, doubleQuoted, singleQuoted, bare) => {
|
|
const value = String(doubleQuoted ?? singleQuoted ?? bare ?? "").trim();
|
|
if (!value || /^(?:data:|blob:|#|[a-z][a-z\d+.-]*:|\/\/)/i.test(value))
|
|
return whole;
|
|
try {
|
|
return `url(${JSON.stringify(new URL(value, sourceUrl).href)})`;
|
|
} catch {
|
|
return whole;
|
|
}
|
|
});
|
|
return Buffer.from(rewritten);
|
|
}
|
|
function rewriteModuleImports(bytes, sourceUrl) {
|
|
const source = bytes.toString("utf8");
|
|
const absolute = (specifier) => {
|
|
try {
|
|
return new URL(specifier, sourceUrl).href;
|
|
} catch {
|
|
return specifier;
|
|
}
|
|
};
|
|
const tokens = [];
|
|
const isIdentifierStart = (char) => /[A-Za-z_$]/.test(char);
|
|
const isIdentifierPart = (char) => /[\w$]/.test(char);
|
|
let i = 0;
|
|
let previousToken = "";
|
|
while (i < source.length) {
|
|
const char = source[i];
|
|
if (/\s/.test(char)) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (char === "/" && source[i + 1] === "/") {
|
|
i += 2;
|
|
while (i < source.length && source[i] !== `
|
|
`)
|
|
i++;
|
|
continue;
|
|
}
|
|
if (char === "/" && source[i + 1] === "*") {
|
|
i += 2;
|
|
while (i < source.length && !(source[i] === "*" && source[i + 1] === "/"))
|
|
i++;
|
|
i = Math.min(source.length, i + 2);
|
|
continue;
|
|
}
|
|
if (char === "/" && !["", "(", "[", "{", "=", ":", ",", ";", "!", "?", "&&", "||", "=>", "return"].includes(previousToken)) {
|
|
tokens.push({ kind: "punctuation", value: "/", start: i, end: i + 1 });
|
|
previousToken = "/";
|
|
i++;
|
|
continue;
|
|
}
|
|
if (char === "/" && ["(", "[", "{", "=", ":", ",", ";", "!", "?", "&&", "||", "=>", "return"].includes(previousToken)) {
|
|
const start = i++;
|
|
let inClass = false;
|
|
while (i < source.length) {
|
|
if (source[i] === "\\") {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (source[i] === "[")
|
|
inClass = true;
|
|
else if (source[i] === "]")
|
|
inClass = false;
|
|
else if (source[i] === "/" && !inClass) {
|
|
i++;
|
|
break;
|
|
} else if (source[i] === `
|
|
`)
|
|
break;
|
|
i++;
|
|
}
|
|
while (/[A-Za-z]/.test(source[i] || ""))
|
|
i++;
|
|
tokens.push({ kind: "punctuation", value: "regex", start, end: i });
|
|
previousToken = "regex";
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'") {
|
|
const start = i++;
|
|
while (i < source.length) {
|
|
if (source[i] === "\\") {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (source[i++] === char)
|
|
break;
|
|
}
|
|
const raw = source.slice(start + 1, Math.max(start + 1, i - 1));
|
|
tokens.push({ kind: "string", value: raw, start, end: i });
|
|
previousToken = "string";
|
|
continue;
|
|
}
|
|
if (char === "`") {
|
|
const start = i++;
|
|
while (i < source.length) {
|
|
if (source[i] === "\\") {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (source[i++] === "`")
|
|
break;
|
|
}
|
|
tokens.push({ kind: "punctuation", value: "template", start, end: i });
|
|
previousToken = "template";
|
|
continue;
|
|
}
|
|
if (isIdentifierStart(char)) {
|
|
const start = i++;
|
|
while (i < source.length && isIdentifierPart(source[i]))
|
|
i++;
|
|
const value = source.slice(start, i);
|
|
tokens.push({ kind: "identifier", value, start, end: i });
|
|
previousToken = value;
|
|
continue;
|
|
}
|
|
const start = i++;
|
|
const value = source.slice(start, i);
|
|
tokens.push({ kind: "punctuation", value, start, end: i });
|
|
previousToken = value;
|
|
}
|
|
const edits = [];
|
|
for (let t = 0;t < tokens.length; t++) {
|
|
const token = tokens[t];
|
|
if (token.kind !== "identifier" || token.value !== "import" && token.value !== "export")
|
|
continue;
|
|
const next = tokens[t + 1];
|
|
if (token.value === "import" && next?.value === "(") {
|
|
const specifier = tokens[t + 2];
|
|
if (specifier?.kind === "string") {
|
|
edits.push({ start: specifier.start, end: specifier.end, replacement: JSON.stringify(absolute(specifier.value)) });
|
|
}
|
|
continue;
|
|
}
|
|
if (token.value === "import" && next?.kind === "string") {
|
|
edits.push({ start: next.start, end: next.end, replacement: JSON.stringify(absolute(next.value)) });
|
|
continue;
|
|
}
|
|
if (token.value === "import" && next?.value === ".")
|
|
continue;
|
|
for (let j = t + 1;j < tokens.length && j <= t + 256; j++) {
|
|
const candidate = tokens[j];
|
|
if (candidate.value === ";" || j > t + 1 && candidate.kind === "identifier" && (candidate.value === "import" || candidate.value === "export"))
|
|
break;
|
|
if (candidate.kind === "identifier" && candidate.value === "from" && tokens[j + 1]?.kind === "string") {
|
|
const specifier = tokens[j + 1];
|
|
edits.push({ start: specifier.start, end: specifier.end, replacement: JSON.stringify(absolute(specifier.value)) });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let rewritten = source;
|
|
for (const edit of edits.sort((a, b) => b.start - a.start)) {
|
|
rewritten = rewritten.slice(0, edit.start) + edit.replacement + rewritten.slice(edit.end);
|
|
}
|
|
return Buffer.from(rewritten);
|
|
}
|
|
function woCacheUrl(hash, resourceUrl, isStylesheet) {
|
|
const source = Buffer.from(resourceUrl).toString("base64url");
|
|
return `wo-cache://cache/${hash}/${isStylesheet ? "style.css" : "script.js"}/${source}`;
|
|
}
|
|
function resourceApprovalKey(pageUrl, resourceUrl) {
|
|
return createHash2("sha256").update(`${pageUrl}
|
|
${resourceUrl}`).digest("hex");
|
|
}
|
|
function apiConsentKey(site) {
|
|
let origin = "";
|
|
try {
|
|
origin = new URL(site.pageUrl).origin;
|
|
} catch {}
|
|
const identity = {
|
|
origin,
|
|
appId: site.appId,
|
|
indexSha256: site.indexSha256,
|
|
scriptSha256: site.scriptBodies.map((script) => script.hash).sort(),
|
|
capabilities: [...site.capabilities].sort()
|
|
};
|
|
return createHash2("sha256").update(JSON.stringify(identity)).digest("hex");
|
|
}
|
|
function apiConsentPath(key) {
|
|
return joinPath(app.getPath("userData"), "wo-api-consents", `${key}.json`);
|
|
}
|
|
async function loadApiConsent(site) {
|
|
if (!site.trusted || !site.indexSha256)
|
|
return null;
|
|
try {
|
|
const key = apiConsentKey(site);
|
|
const record = JSON.parse(await fs.promises.readFile(apiConsentPath(key), "utf8"));
|
|
const origin = new URL(site.pageUrl).origin;
|
|
const scriptSha256 = site.scriptBodies.map((script) => script.hash).sort();
|
|
if (record.key !== key || record.origin !== origin || record.indexSha256 !== site.indexSha256 || JSON.stringify(record.scriptSha256) !== JSON.stringify(scriptSha256) || !Array.isArray(record.capabilities) || JSON.stringify([...record.capabilities].sort()) !== JSON.stringify([...site.capabilities].sort()) || !Array.isArray(record.granted) || !record.granted.every((capability) => site.capabilities.includes(capability)))
|
|
return null;
|
|
return record.granted;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
async function saveApiConsent(site, granted) {
|
|
const key = apiConsentKey(site);
|
|
const origin = new URL(site.pageUrl).origin;
|
|
const path = apiConsentPath(key);
|
|
const directory = dirname(path);
|
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
const record = {
|
|
key,
|
|
origin,
|
|
appId: site.appId,
|
|
indexSha256: site.indexSha256,
|
|
scriptSha256: site.scriptBodies.map((script) => script.hash).sort(),
|
|
capabilities: [...site.capabilities].sort(),
|
|
granted: [...granted].sort(),
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
await fs.promises.mkdir(directory, { recursive: true, mode: 448 });
|
|
await fs.promises.writeFile(temporary, JSON.stringify(record, null, 2), { mode: 384 });
|
|
await fs.promises.rename(temporary, path);
|
|
}
|
|
var MAX_SITE_SETTINGS_BYTES = 64 * 1024;
|
|
var SETTINGS_KEY_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
var siteSettingsTransactions = new Map;
|
|
var siteSecretTransactions = new Map;
|
|
function siteSettingsPath(origin) {
|
|
const originHash = createHash2("sha256").update(origin).digest("hex");
|
|
return joinPath(app.getPath("userData"), "site-settings", `${originHash}.json`);
|
|
}
|
|
function siteSecretsPath(origin) {
|
|
const originHash = createHash2("sha256").update(origin).digest("hex");
|
|
return joinPath(app.getPath("userData"), "site-secrets", `${originHash}.json`);
|
|
}
|
|
function secureSecretStorageAvailable() {
|
|
if (!safeStorage.isEncryptionAvailable())
|
|
return false;
|
|
const backend = safeStorage.getSelectedStorageBackend();
|
|
return ["gnome_libsecret", "kwallet", "kwallet5", "kwallet6"].includes(backend);
|
|
}
|
|
async function readSiteSecrets(origin) {
|
|
if (!secureSecretStorageAvailable())
|
|
throw new Error("Secure secret storage requires GNOME Keyring/Secret Service or KDE Wallet");
|
|
let stored;
|
|
try {
|
|
const parsed = JSON.parse(await fs.promises.readFile(siteSecretsPath(origin), "utf8"));
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
return Object.create(null);
|
|
stored = parsed;
|
|
} catch (error) {
|
|
if (error.code === "ENOENT")
|
|
return Object.create(null);
|
|
throw error;
|
|
}
|
|
const decrypted = Object.create(null);
|
|
for (const [key, encoded] of Object.entries(stored)) {
|
|
if (typeof encoded !== "string")
|
|
continue;
|
|
decrypted[key] = safeStorage.decryptString(Buffer.from(encoded, "base64"));
|
|
}
|
|
return decrypted;
|
|
}
|
|
async function updateSiteSecrets(origin, update) {
|
|
if (!secureSecretStorageAvailable())
|
|
throw new Error("Secure secret storage requires GNOME Keyring/Secret Service or KDE Wallet");
|
|
const previous = siteSecretTransactions.get(origin) ?? Promise.resolve();
|
|
const transaction = previous.catch(() => {
|
|
return;
|
|
}).then(async () => {
|
|
const secrets = await readSiteSecrets(origin);
|
|
update(secrets);
|
|
const encrypted = Object.create(null);
|
|
for (const [key, value] of Object.entries(secrets)) {
|
|
encrypted[key] = safeStorage.encryptString(value).toString("base64");
|
|
}
|
|
const path = siteSecretsPath(origin);
|
|
const directory = dirname(path);
|
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
await fs.promises.mkdir(directory, { recursive: true, mode: 448 });
|
|
await fs.promises.writeFile(temporary, JSON.stringify(encrypted), { mode: 384 });
|
|
await fs.promises.rename(temporary, path);
|
|
});
|
|
siteSecretTransactions.set(origin, transaction);
|
|
try {
|
|
await transaction;
|
|
} finally {
|
|
if (siteSecretTransactions.get(origin) === transaction)
|
|
siteSecretTransactions.delete(origin);
|
|
}
|
|
}
|
|
async function readSiteSettings(origin) {
|
|
try {
|
|
const value = JSON.parse(await fs.promises.readFile(siteSettingsPath(origin), "utf8"));
|
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
return {};
|
|
return Object.assign(Object.create(null), value);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
async function writeSiteSettings(origin, settings) {
|
|
const path = siteSettingsPath(origin);
|
|
const directory = dirname(path);
|
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
const bytes = JSON.stringify(settings);
|
|
if (Buffer.byteLength(bytes) > 1024 * 1024)
|
|
throw new Error("site settings exceed 1 MiB");
|
|
await fs.promises.mkdir(directory, { recursive: true, mode: 448 });
|
|
await fs.promises.writeFile(temporary, bytes, { mode: 384 });
|
|
await fs.promises.rename(temporary, path);
|
|
}
|
|
async function updateSiteSettings(origin, update) {
|
|
const previous = siteSettingsTransactions.get(origin) ?? Promise.resolve();
|
|
const transaction = previous.catch(() => {
|
|
return;
|
|
}).then(async () => {
|
|
const settings = await readSiteSettings(origin);
|
|
update(settings);
|
|
await writeSiteSettings(origin, settings);
|
|
});
|
|
siteSettingsTransactions.set(origin, transaction);
|
|
try {
|
|
await transaction;
|
|
} finally {
|
|
if (siteSettingsTransactions.get(origin) === transaction)
|
|
siteSettingsTransactions.delete(origin);
|
|
}
|
|
}
|
|
function resourceCacheDirectory() {
|
|
return joinPath(app.getPath("userData"), "wo-approved-resources");
|
|
}
|
|
async function loadApprovedResource(pageUrl, resourceUrl) {
|
|
const directory = resourceCacheDirectory();
|
|
const key = resourceApprovalKey(pageUrl, resourceUrl);
|
|
try {
|
|
const record = JSON.parse(await fs.promises.readFile(joinPath(directory, `${key}.json`), "utf8"));
|
|
if (record.pageUrl !== pageUrl || record.resourceUrl !== resourceUrl || typeof record.hash !== "string" || !/^[a-f0-9]{64}$/.test(record.hash))
|
|
return null;
|
|
const bytes = await fs.promises.readFile(joinPath(directory, `${record.hash}.bin`));
|
|
const actualHash = createHash2("sha256").update(bytes).digest("hex");
|
|
if (actualHash !== record.hash || bytes.byteLength > MAX_APPROVED_RESOURCE_BYTES)
|
|
return null;
|
|
verifiedScriptCache.set(actualHash, bytes);
|
|
debugLog(`[Wo] Loaded user-approved resource cache ${actualHash.slice(0, 12)} for ${resourceUrl}`);
|
|
return actualHash;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
async function fetchApprovedResourceBytes(resourceUrl) {
|
|
const parsed = new URL(resourceUrl);
|
|
let bytes;
|
|
if (parsed.protocol === "file:") {
|
|
const path = fileURLToPath2(parsed);
|
|
const fileStat = await fs.promises.stat(path);
|
|
if (fileStat.size > MAX_APPROVED_RESOURCE_BYTES)
|
|
throw new Error("resource exceeds the 16 MiB approval cache limit");
|
|
bytes = await fs.promises.readFile(path);
|
|
} else if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
let response;
|
|
try {
|
|
response = await fetch(resourceUrl, { redirect: "error" });
|
|
} catch (error) {
|
|
if (parsed.protocol !== "http:" || parsed.hostname !== "localhost")
|
|
throw error;
|
|
parsed.hostname = "127.0.0.1";
|
|
response = await fetch(parsed.href, { redirect: "error" });
|
|
}
|
|
if (!response.ok)
|
|
throw new Error(`request returned HTTP ${response.status}`);
|
|
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
if (contentLength > MAX_APPROVED_RESOURCE_BYTES)
|
|
throw new Error("resource exceeds the 16 MiB approval cache limit");
|
|
if (!response.body)
|
|
throw new Error("resource response has no body");
|
|
const reader = response.body.getReader();
|
|
const chunks = [];
|
|
let totalBytes = 0;
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done)
|
|
break;
|
|
totalBytes += value.byteLength;
|
|
if (totalBytes > MAX_APPROVED_RESOURCE_BYTES) {
|
|
await reader.cancel();
|
|
throw new Error("resource exceeds the 16 MiB approval cache limit");
|
|
}
|
|
chunks.push(Buffer.from(value));
|
|
}
|
|
bytes = Buffer.concat(chunks, totalBytes);
|
|
} else {
|
|
throw new Error(`unsupported resource protocol: ${parsed.protocol}`);
|
|
}
|
|
if (bytes.byteLength === 0)
|
|
throw new Error("resource is empty");
|
|
if (bytes.byteLength > MAX_APPROVED_RESOURCE_BYTES)
|
|
throw new Error("resource exceeds the 16 MiB approval cache limit");
|
|
return bytes;
|
|
}
|
|
async function persistApprovedResource(pageUrl, resourceUrl, hash, bytes) {
|
|
const directory = resourceCacheDirectory();
|
|
const key = resourceApprovalKey(pageUrl, resourceUrl);
|
|
await fs.promises.mkdir(directory, { recursive: true, mode: 448 });
|
|
const bytesPath = joinPath(directory, `${hash}.bin`);
|
|
try {
|
|
await fs.promises.access(bytesPath);
|
|
} catch {
|
|
await fs.promises.writeFile(bytesPath, bytes, { mode: 384, flag: "wx" }).catch((error) => {
|
|
if (error.code !== "EEXIST")
|
|
throw error;
|
|
});
|
|
}
|
|
const recordPath = joinPath(directory, `${key}.json`);
|
|
const temporaryPath = `${recordPath}.${randomUUID()}.tmp`;
|
|
await fs.promises.writeFile(temporaryPath, JSON.stringify({ pageUrl, resourceUrl, hash }), { mode: 384 });
|
|
await fs.promises.rename(temporaryPath, recordPath);
|
|
}
|
|
function promptForResourceApproval(pageUrl, resourceUrl, resourceType, portalStyles) {
|
|
const prompt = resourceConsentQueue.then(async () => {
|
|
if (approvedAllResourcePages.has(pageUrl))
|
|
return "allow";
|
|
debugLog(`[Wo] Waiting for user approval to fetch and cache: ${resourceUrl}`);
|
|
const decision = await showResourceConsentOverlay(pageUrl, resourceUrl, resourceType, portalStyles);
|
|
if (decision === "allow-all")
|
|
approvedAllResourcePages.add(pageUrl);
|
|
debugLog(`[Wo] User ${decision} resource: ${resourceUrl}`);
|
|
return decision;
|
|
});
|
|
resourceConsentQueue = prompt.then(() => {
|
|
return;
|
|
}, () => {
|
|
return;
|
|
});
|
|
return prompt;
|
|
}
|
|
async function showResourceConsentOverlay(pageUrl, resourceUrl, resourceType, portalStyles) {
|
|
const targetWindow = mainWindow;
|
|
if (!targetWindow || targetWindow.isDestroyed())
|
|
return "deny";
|
|
if (!mainPageLoadFinished && !resourceConsentUiActive) {
|
|
await new Promise((resolve) => {
|
|
const ready = () => {
|
|
clearTimeout(timeout);
|
|
targetWindow.webContents.removeListener("did-finish-load", ready);
|
|
resolve();
|
|
};
|
|
const timeout = setTimeout(ready, 5000);
|
|
targetWindow.webContents.once("did-finish-load", ready);
|
|
});
|
|
}
|
|
if (targetWindow.isDestroyed())
|
|
return "deny";
|
|
const requestId = randomUUID();
|
|
const token = randomUUID();
|
|
const payload = JSON.stringify({ requestId, token, pageUrl, resourceUrl, resourceType }).replace(/</g, "\\u003c");
|
|
const safePortalStyles = rewriteStylesheetUrls(Buffer.from(portalStyles), pageUrl).toString("utf8").replace(/<\/style/gi, "<\\/style");
|
|
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Wo security portal</title>
|
|
<style>
|
|
:root{color-scheme:dark;font:14px/1.5 system-ui,sans-serif;--wo-accent:#6ca8ff;--wo-surface-raised:#272b34;--wo-border:rgba(255,255,255,.14);--wo-text:#f4f5f7;--wo-muted:#a8adb7;--wo-radius:10px;--wo-shadow:0 12px 36px rgba(0,0,0,.32);--wo-portal-overlay:rgba(0,0,0,.56);--wo-portal-surface:var(--wo-surface-raised);--wo-portal-border:var(--wo-border);--wo-portal-text:var(--wo-text);--wo-portal-muted:var(--wo-muted);--wo-portal-accent:var(--wo-accent);--wo-portal-radius:calc(var(--wo-radius) + 2px);--wo-portal-shadow:0 18px 64px rgba(0,0,0,.46)}
|
|
*{box-sizing:border-box}html,body{width:100%;height:100%;margin:0;color:var(--wo-portal-text);font:inherit}
|
|
body{min-height:100vh;padding:24px;display:grid;place-items:center}
|
|
main{width:min(620px,100%);padding:28px;border:1px solid var(--wo-portal-border);}
|
|
.eyebrow{color:#9ca6ba;font-size:12px;text-transform:uppercase;letter-spacing:.08em}h1{font-size:22px;line-height:1.25;margin:8px 0 10px}
|
|
p{color:var(--wo-portal-muted);margin:0 0 20px}.resource{padding:14px;border:1px solid var(--wo-portal-border);border-radius:8px;background:var(--wo-portal-surface-soft,rgba(0,0,0,.16));overflow-wrap:anywhere}
|
|
.resource b{display:block;color:#9ca6ba;font-size:11px;font-weight:600;margin:0 0 3px}
|
|
.resource code{font:12px/1.5 ui-monospace,monospace;color:var(--wo-portal-text);user-select:text}.url{margin-top:12px}
|
|
aside{border-left:3px solid var(--wo-portal-accent);padding:9px 12px;background:var(--wo-portal-surface-soft,rgba(0,0,0,.16));color:var(--wo-portal-muted);margin:18px 0 24px}
|
|
footer{display:flex;justify-content:flex-end;gap:10px;flex-wrap:wrap}button{font:600 14px system-ui,sans-serif;padding:10px 14px;cursor:pointer}
|
|
button:focus-visible{outline:2px solid var(--wo-portal-accent);outline-offset:2px}.allow[data-primary="true"]{background:var(--wo-portal-accent);color:#fff}
|
|
</style><style>${safePortalStyles}</style></head><body class="wo-portal-backdrop" data-wo-portal="resource-consent"><main class="wo-portal-surface" role="dialog" aria-modal="true" aria-labelledby="title">
|
|
<h1 id="title">Allow this page to load a resource?</h1><p>This resource is not covered by the page's verified Wo manifest.</p>
|
|
<div class="resource"><b>Resource type</b><code id="type"></code><div class="url"><b>Requested URL</b><code id="url"></code></div><div class="url"><b>Page</b><code id="page"></code></div></div>
|
|
<p>Approve all allows this page's future script, worker, and stylesheet requests for this session.</p>
|
|
<p id="status" aria-live="polite"></p>
|
|
<footer><button class="deny wo-portal-control" id="deny" type="button">Block</button><button class="allow wo-portal-control" data-primary="true" id="allow" type="button">Fetch, cache, and allow</button><button class="allow-all wo-portal-control" id="allow-all" type="button" title="Allow and cache this page's future script, worker, and stylesheet requests for this session">Approve all</button></footer></main>
|
|
<script>const request=${payload};for(const [id,value] of [['type',request.resourceType],['url',request.resourceUrl],['page',request.pageUrl]])document.getElementById(id).textContent=value;
|
|
const decide=async decision=>{const result=await window.woResourceConsent?.respond(request.requestId,request.token,decision);if(!result?.ok)document.getElementById('status').textContent='Wo could not submit this decision. Please try again.';};
|
|
document.getElementById('allow').addEventListener('click',()=>void decide('allow'));document.getElementById('allow-all').addEventListener('click',()=>void decide('allow-all'));document.getElementById('deny').addEventListener('click',()=>void decide('deny'));document.getElementById('allow').focus();</script>
|
|
</body></html>`;
|
|
return new Promise((resolve) => {
|
|
resourceApprovalPending = { requestId, token, resolve };
|
|
resourceConsentUiActive = true;
|
|
targetWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}#wo-resource-consent`).then(() => {
|
|
debugLog(`[Wo] Displayed resource approval portal in the compositor root surface: ${resourceUrl}`);
|
|
}).catch((error) => {
|
|
if (resourceApprovalPending?.requestId === requestId)
|
|
resourceApprovalPending = null;
|
|
resourceConsentUiActive = false;
|
|
debugLog(`[Wo] Could not load resource approval portal: ${String(error)}`);
|
|
resolve("deny");
|
|
if (!targetWindow.isDestroyed()) {
|
|
targetWindow.loadURL(pageUrl).catch((restoreError) => {
|
|
debugLog(`[Wo] Could not restore page after approval UI failure: ${String(restoreError)}`);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
function handleResourceConsentNavigation(event, targetUrl) {
|
|
if (!targetUrl.startsWith("wo-consent://"))
|
|
return;
|
|
event.preventDefault();
|
|
try {
|
|
const decision = new URL(targetUrl);
|
|
if (decision.hostname !== "decision")
|
|
return;
|
|
const action = decision.searchParams.get("action");
|
|
resolveResourceConsent(decision.pathname.slice(1), decision.searchParams.get("token") || "", action === "allow-all" ? "allow-all" : action === "allow" ? "allow" : "deny");
|
|
} catch {}
|
|
}
|
|
function resolveResourceConsent(requestId, token, decision) {
|
|
const pending = resourceApprovalPending;
|
|
if (!resourceConsentUiActive || !pending || pending.requestId !== requestId || pending.token !== token)
|
|
return false;
|
|
resourceApprovalPending = null;
|
|
pending.resolve(decision);
|
|
return true;
|
|
}
|
|
async function approveAndCacheResource(pageUrl, resourceUrl, resourceType, portalStyles) {
|
|
const requestKey = resourceApprovalKey(pageUrl, resourceUrl);
|
|
const pending = approvedResourceRequests.get(requestKey);
|
|
if (pending)
|
|
return pending;
|
|
const operation = (async () => {
|
|
const cachedHash = await loadApprovedResource(pageUrl, resourceUrl);
|
|
if (cachedHash)
|
|
return cachedHash;
|
|
if (await promptForResourceApproval(pageUrl, resourceUrl, resourceType, portalStyles) === "deny")
|
|
return null;
|
|
const bytes = await fetchApprovedResourceBytes(resourceUrl);
|
|
const hash = createHash2("sha256").update(bytes).digest("hex");
|
|
await persistApprovedResource(pageUrl, resourceUrl, hash, bytes);
|
|
verifiedScriptCache.set(hash, bytes);
|
|
debugLog(`[Wo] Cached user-approved resource ${hash.slice(0, 12)} (${bytes.byteLength} bytes): ${resourceUrl}`);
|
|
return hash;
|
|
})();
|
|
approvedResourceRequests.set(requestKey, operation);
|
|
try {
|
|
return await operation;
|
|
} finally {
|
|
approvedResourceRequests.delete(requestKey);
|
|
}
|
|
}
|
|
var DEBUG_LOG = "/tmp/wo-electron-debug.log";
|
|
var _logBuffer = [];
|
|
var _logTimer = null;
|
|
function _flushLog() {
|
|
if (_logBuffer.length === 0)
|
|
return;
|
|
try {
|
|
fs.appendFileSync(DEBUG_LOG, _logBuffer.join(""));
|
|
} catch (e) {}
|
|
_logBuffer = [];
|
|
_logTimer = null;
|
|
}
|
|
function debugLog(...args) {
|
|
const msg = `[${new Date().toISOString()}] ` + args.join(" ") + `
|
|
`;
|
|
_logBuffer.push(msg);
|
|
if (!_logTimer) {
|
|
_logTimer = setTimeout(_flushLog, 100);
|
|
}
|
|
console.log(...args);
|
|
}
|
|
debugLog("[Wo] Electron starting, NODE_VERSION=", process.version);
|
|
var requestedOzonePlatform = (process.env.WO_ELECTRON_OZONE_PLATFORM || "auto").toLowerCase();
|
|
var defaultGpuBackend = "auto";
|
|
var requestedGpuBackend = (process.env.WO_ELECTRON_GPU_BACKEND || defaultGpuBackend).toLowerCase();
|
|
var validatedGpuBackend = ["vulkan", "gl", "angle", "desktop", "auto"].includes(requestedGpuBackend) ? requestedGpuBackend : defaultGpuBackend;
|
|
var waylandOzone = requestedOzonePlatform === "wayland";
|
|
var gpuBackend = waylandOzone && ["auto", "vulkan"].includes(validatedGpuBackend) ? "angle" : validatedGpuBackend;
|
|
if (waylandOzone && validatedGpuBackend === "vulkan") {
|
|
debugLog("[Wo] Vulkan was requested, but Chromium Vulkan is incompatible with native Wayland; using ANGLE OpenGL.");
|
|
} else if (waylandOzone && validatedGpuBackend === "auto") {
|
|
debugLog("[Wo] Native Wayland uses ANGLE OpenGL to avoid Chromium selecting its incompatible Vulkan surface path.");
|
|
}
|
|
if (gpuBackend === "desktop") {
|
|
app.commandLine.appendSwitch("use-gl", "desktop");
|
|
} else if (gpuBackend === "angle") {
|
|
app.commandLine.appendSwitch("use-gl", "angle");
|
|
app.commandLine.appendSwitch("use-angle", "gl");
|
|
} else if (gpuBackend !== "auto") {
|
|
app.commandLine.appendSwitch("use-gl", "angle");
|
|
app.commandLine.appendSwitch("use-angle", gpuBackend);
|
|
}
|
|
if (requestedOzonePlatform === "wayland" || requestedOzonePlatform === "x11") {
|
|
app.commandLine.appendSwitch("ozone-platform", requestedOzonePlatform);
|
|
}
|
|
app.commandLine.appendSwitch("ignore-gpu-blocklist");
|
|
var chromiumFeatures = [
|
|
"VaapiVideoDecoder",
|
|
"VaapiVideoEncoder",
|
|
"SharedArrayBuffer"
|
|
];
|
|
if (gpuBackend === "vulkan") {
|
|
chromiumFeatures.push("Vulkan", "DefaultANGLEVulkan", "VulkanFromANGLE");
|
|
} else if (waylandOzone || gpuBackend !== "auto") {
|
|
app.commandLine.appendSwitch("disable-vulkan");
|
|
app.commandLine.appendSwitch("disable-features", "Vulkan,DefaultANGLEVulkan,VulkanFromANGLE");
|
|
}
|
|
app.commandLine.appendSwitch("enable-features", chromiumFeatures.join(","));
|
|
app.commandLine.appendSwitch("enable-accelerated-video-decode");
|
|
app.commandLine.appendSwitch("num-raster-threads", "4");
|
|
app.commandLine.appendSwitch("disable-renderer-backgrounding");
|
|
debugLog(`[Wo] GPU backend preference: ${gpuBackend}; Ozone platform: ${requestedOzonePlatform}`);
|
|
function logGraphicsRuntime() {
|
|
const relevantEnv = [
|
|
"WAYLAND_DISPLAY",
|
|
"DISPLAY",
|
|
"XDG_SESSION_TYPE",
|
|
"XDG_RUNTIME_DIR",
|
|
"EGL_PLATFORM",
|
|
"__EGL_VENDOR_LIBRARY_FILENAMES",
|
|
"__GLX_VENDOR_LIBRARY_NAME",
|
|
"GBM_BACKENDS_PATH",
|
|
"LIBGL_DRIVERS_PATH",
|
|
"MESA_LOADER_DRIVER_OVERRIDE",
|
|
"DRI_PRIME",
|
|
"NVIDIA_VISIBLE_DEVICES"
|
|
];
|
|
const env = Object.fromEntries(relevantEnv.filter((key) => process.env[key] !== undefined).map((key) => [key, process.env[key]]));
|
|
const renderNode = process.env.WO_DRM_RENDER_NODE || "/dev/dri/renderD128";
|
|
let renderNodeAccess;
|
|
try {
|
|
const stat = fs.statSync(renderNode);
|
|
fs.accessSync(renderNode, fs.constants.R_OK | fs.constants.W_OK);
|
|
renderNodeAccess = { path: renderNode, mode: (stat.mode & 511).toString(8), readable: true, writable: true };
|
|
} catch (error) {
|
|
renderNodeAccess = { path: renderNode, error: String(error) };
|
|
}
|
|
const switches = ["ozone-platform", "use-gl", "use-angle", "disable-gpu", "disable-vulkan", "disable-features"].map((name) => ({ name, present: app.commandLine.hasSwitch(name), value: app.commandLine.getSwitchValue(name) }));
|
|
debugLog("[Wo] Graphics process context:", JSON.stringify({
|
|
uid: typeof process.getuid === "function" ? process.getuid() : null,
|
|
groups: typeof process.getgroups === "function" ? process.getgroups() : [],
|
|
env,
|
|
renderNodeAccess,
|
|
switches
|
|
}));
|
|
}
|
|
logGraphicsRuntime();
|
|
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 SYSTEM_SHELL = process.env.WO_SYSTEM_SHELL === "1";
|
|
function isLoopbackHttp(url) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]", "::1"].includes(parsed.hostname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
var {
|
|
name = "default",
|
|
url = null,
|
|
html = null,
|
|
width = 1920,
|
|
height = 1080
|
|
} = WINDOW_CONFIG;
|
|
var initialWindowWidth = Number.isInteger(width) && width > 0 ? Math.min(width, 16384) : 1920;
|
|
var initialWindowHeight = Number.isInteger(height) && height > 0 ? Math.min(height, 16384) : 1080;
|
|
var mainWindow = null;
|
|
var resourceConsentUiActive = false;
|
|
var mainPageLoadFinished = false;
|
|
var resourceApprovalPending = null;
|
|
var displayScreen = null;
|
|
var apiGrants = new Set;
|
|
var apiGrantPageUrl = null;
|
|
var lockedScriptUrls = new Map;
|
|
var framePacer = null;
|
|
var framePacerRate = 0;
|
|
var gpuCompositingStatus = "pending";
|
|
var webglStatus = "pending";
|
|
var sharedDmabufStatus = null;
|
|
var offscreenSharedTextureEnabled = false;
|
|
var ipcSocket = null;
|
|
var portalUiServer = null;
|
|
var portalPendingRequests = new Map;
|
|
var portalUiEvents = new Map;
|
|
var portalUiRendererReady = false;
|
|
var portalUiRendererGeneration = 0;
|
|
var PORTAL_UI_SOCKET = joinPath(process.env.XDG_RUNTIME_DIR || "/tmp", "wo-portal-ui.sock");
|
|
var ipcConnected = false;
|
|
var activeSiteHashes = null;
|
|
var ipcReconnectTimer = null;
|
|
var dmabufSender = null;
|
|
var damageBuffer = null;
|
|
var nativeDmabuf = null;
|
|
var audioCapture = null;
|
|
var compositorRxBuffer = Buffer.alloc(64 * 1024);
|
|
var rxWriteOffset = 0;
|
|
var rxReadOffset = 0;
|
|
var surfaceSabCache = new Map;
|
|
var surfaceUpdatePending = new Map;
|
|
var surfaceUpdateGeneration = 0;
|
|
var surfaceUpdateFlushScheduled = false;
|
|
var shmFdCache = new Map;
|
|
function isDisabledGpuFeature(status) {
|
|
return status.startsWith("disabled") || status.startsWith("unavailable") || status.startsWith("failed");
|
|
}
|
|
function getPerformanceStatus() {
|
|
const reasons = [];
|
|
if (isDisabledGpuFeature(gpuCompositingStatus))
|
|
reasons.push("GPU compositing is disabled");
|
|
if (isDisabledGpuFeature(webglStatus))
|
|
reasons.push("WebGL is unavailable");
|
|
if (sharedDmabufStatus === false)
|
|
reasons.push("Electron is using bitmap frames instead of shared GPU DMA-BUFs");
|
|
return {
|
|
limited: reasons.length > 0,
|
|
gpuCompositing: gpuCompositingStatus,
|
|
webgl: webglStatus,
|
|
sharedDmabuf: sharedDmabufStatus,
|
|
reasons
|
|
};
|
|
}
|
|
function publishPerformanceStatus() {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send("wo:performance-status", getPerformanceStatus());
|
|
}
|
|
}
|
|
function rxAppend(chunk) {
|
|
const needed = rxWriteOffset + chunk.length;
|
|
if (needed > compositorRxBuffer.length) {
|
|
if (rxReadOffset > 0) {
|
|
compositorRxBuffer.copy(compositorRxBuffer, 0, rxReadOffset, rxWriteOffset);
|
|
rxWriteOffset -= rxReadOffset;
|
|
rxReadOffset = 0;
|
|
}
|
|
if (rxWriteOffset + chunk.length > compositorRxBuffer.length) {
|
|
const next = Buffer.alloc(Math.max(compositorRxBuffer.length * 2, rxWriteOffset + chunk.length));
|
|
compositorRxBuffer.copy(next, 0, 0, rxWriteOffset);
|
|
compositorRxBuffer = next;
|
|
}
|
|
}
|
|
chunk.copy(compositorRxBuffer, rxWriteOffset);
|
|
rxWriteOffset += chunk.length;
|
|
}
|
|
function rxAvailable() {
|
|
return rxWriteOffset - rxReadOffset;
|
|
}
|
|
function rxConsume(n) {
|
|
rxReadOffset += n;
|
|
if (rxReadOffset > compositorRxBuffer.length / 2) {
|
|
compositorRxBuffer.copy(compositorRxBuffer, 0, rxReadOffset, rxWriteOffset);
|
|
rxWriteOffset -= rxReadOffset;
|
|
rxReadOffset = 0;
|
|
}
|
|
}
|
|
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 getOrCreateSab(windowName, width, height, stride) {
|
|
const existing = surfaceSabCache.get(windowName);
|
|
const neededSize = stride * height;
|
|
if (existing && existing.sab.byteLength >= neededSize && existing.width === width && existing.height === height && existing.stride === stride) {
|
|
return existing;
|
|
}
|
|
const sab = new SharedArrayBuffer(neededSize);
|
|
const entry = { sab, width, height, stride };
|
|
surfaceSabCache.set(windowName, entry);
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send("wo:surface-sab", {
|
|
name: windowName,
|
|
sab,
|
|
width,
|
|
height,
|
|
stride
|
|
});
|
|
}
|
|
return entry;
|
|
}
|
|
function flushSurfaceUpdates() {
|
|
surfaceUpdateFlushScheduled = false;
|
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
surfaceUpdatePending.clear();
|
|
return;
|
|
}
|
|
for (const entry of surfaceUpdatePending.values()) {
|
|
mainWindow.webContents.send("wo:surface-update", entry);
|
|
}
|
|
surfaceUpdatePending.clear();
|
|
}
|
|
var inFlightFrameSeqs = new Set;
|
|
var MAX_IN_FLIGHT_FRAMES = 2;
|
|
var receivedSocketFds = [];
|
|
var fdReaderSocketFd = null;
|
|
function displayRefreshForWindow() {
|
|
if (!displayScreen)
|
|
return 60;
|
|
const bounds = mainWindow && !mainWindow.isDestroyed() ? mainWindow.getBounds() : null;
|
|
const display = bounds ? displayScreen.getDisplayMatching(bounds) : displayScreen.getPrimaryDisplay();
|
|
return display.displayFrequency;
|
|
}
|
|
function configureFramePacing() {
|
|
if (CLIENT_MODE || !mainWindow || mainWindow.isDestroyed())
|
|
return;
|
|
const rate = chooseFrameRate(Number(WINDOW_CONFIG.fps), displayRefreshForWindow());
|
|
if (rate === framePacerRate && framePacer)
|
|
return;
|
|
framePacerRate = rate;
|
|
if (mainWindow.webContents.isOffscreen() && !mainWindow.webContents.isPainting()) {
|
|
mainWindow.webContents.startPainting();
|
|
}
|
|
mainWindow.webContents.setFrameRate(rate);
|
|
if (framePacer)
|
|
clearInterval(framePacer);
|
|
framePacer = setInterval(() => {
|
|
if (mainWindow && !mainWindow.isDestroyed() && inFlightFrameSeqs.size < MAX_IN_FLIGHT_FRAMES) {
|
|
mainWindow.webContents.invalidate();
|
|
}
|
|
}, frameIntervalMs(rate));
|
|
framePacer.unref();
|
|
debugLog(`[Wo] Offscreen frame pacing: ${rate} Hz (window cap ${WINDOW_CONFIG.fps || "auto"})`);
|
|
}
|
|
function senderHasCapability(sender, capability) {
|
|
if (!mainWindow || mainWindow.isDestroyed() || sender.id !== mainWindow.webContents.id || !apiGrants.has(capability) || !apiGrantPageUrl)
|
|
return false;
|
|
try {
|
|
const approved = new URL(apiGrantPageUrl);
|
|
const current = new URL(sender.getURL());
|
|
approved.hash = "";
|
|
current.hash = "";
|
|
return approved.href === current.href;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
function syscallCapability(type) {
|
|
if (["list_applications", "launch_desktop"].includes(type))
|
|
return "desktop-applications";
|
|
if (["xdg_directories", "resolve_icon"].includes(type))
|
|
return "xdg";
|
|
if (type.startsWith("dbus_"))
|
|
return "dbus";
|
|
if (type === "portal_respond")
|
|
return "portals";
|
|
if (type.startsWith("audio_"))
|
|
return "audio-capture";
|
|
if (["listdir", "read", "write"].includes(type))
|
|
return "filesystem";
|
|
if (["launch", "exec"].includes(type))
|
|
return "process-launch";
|
|
if (["shutdown", "restart", "logout", "lock", "sleep"].includes(type))
|
|
return "system-control";
|
|
if (type === "notify")
|
|
return "notifications";
|
|
if (type === "load_local_file")
|
|
return "filesystem";
|
|
return null;
|
|
}
|
|
function permissionDenied(capability) {
|
|
return { ok: false, error: `Wo capability not granted: ${capability}` };
|
|
}
|
|
var appUsedDamageHelper = false;
|
|
var pointerX = 0;
|
|
var pointerY = 0;
|
|
var encodedWindowNames = new Map;
|
|
function getEncodedWindowName(windowName) {
|
|
let encoded = encodedWindowNames.get(windowName);
|
|
if (!encoded) {
|
|
encoded = Buffer.from(windowName, "utf8");
|
|
encodedWindowNames.set(windowName, encoded);
|
|
}
|
|
return encoded;
|
|
}
|
|
function respondToPortalRequest(requestId, params) {
|
|
const pending = portalPendingRequests.get(requestId);
|
|
if (!pending) {
|
|
return { ok: false, error: `No pending portal request: ${requestId}` };
|
|
}
|
|
const responsePayload = params.response !== undefined ? params.response : {
|
|
allowed: Boolean(params.allowed),
|
|
sourceType: params.type === "window" ? "Window" : "Monitor",
|
|
windowName: typeof params.windowName === "string" ? params.windowName : null
|
|
};
|
|
if (responsePayload === undefined) {
|
|
portalPendingRequests.delete(requestId);
|
|
return { ok: false, error: "Missing portal response payload" };
|
|
}
|
|
try {
|
|
pending.socket.write(`${JSON.stringify(responsePayload)}
|
|
`);
|
|
pending.socket.end();
|
|
portalPendingRequests.delete(requestId);
|
|
portalUiEvents.delete(requestId);
|
|
return { ok: true };
|
|
} catch (error) {
|
|
portalPendingRequests.delete(requestId);
|
|
portalUiEvents.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");
|
|
let nl = received.indexOf(`
|
|
`);
|
|
while (nl >= 0) {
|
|
const line = received.slice(0, nl).trim();
|
|
received = received.slice(nl + 1);
|
|
if (line.length > 0) {
|
|
try {
|
|
const req = JSON.parse(line);
|
|
const requestId = String(req?.requestId ?? req?.sessionId ?? req?.id ?? randomUUID());
|
|
portalPendingRequests.set(requestId, { socket, requestId, payload: req });
|
|
socket.setTimeout(90000, () => {
|
|
const pending = portalPendingRequests.get(requestId);
|
|
if (pending) {
|
|
portalPendingRequests.delete(requestId);
|
|
portalUiEvents.delete(requestId);
|
|
pending.socket.end(JSON.stringify({ error: "timeout", requestId }) + `
|
|
`);
|
|
}
|
|
});
|
|
portalUiEvents.set(requestId, {
|
|
data: {
|
|
requestId,
|
|
payload: req,
|
|
kind: typeof req?.type === "string" ? req.type : typeof req?.kind === "string" ? req.kind : undefined
|
|
},
|
|
sentGeneration: -1
|
|
});
|
|
flushPortalUiEvents();
|
|
} catch {
|
|
socket.end(`{"error":"invalid_json"}
|
|
`);
|
|
}
|
|
}
|
|
nl = received.indexOf(`
|
|
`);
|
|
}
|
|
});
|
|
socket.on("close", () => {
|
|
for (const [requestId, pending] of portalPendingRequests.entries()) {
|
|
if (pending.socket === socket) {
|
|
portalPendingRequests.delete(requestId);
|
|
portalUiEvents.delete(requestId);
|
|
}
|
|
}
|
|
});
|
|
socket.on("error", () => {});
|
|
});
|
|
portalUiServer.listen(PORTAL_UI_SOCKET, () => {
|
|
try {
|
|
fs.chmodSync(PORTAL_UI_SOCKET, 384);
|
|
} catch (err) {
|
|
console.error("[Wo] Could not restrict portal socket permissions:", err);
|
|
}
|
|
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(JSON.stringify({ error: "shutdown", requestId: pending.requestId }) + `
|
|
`);
|
|
} catch {}
|
|
}
|
|
portalPendingRequests.clear();
|
|
portalUiEvents.clear();
|
|
portalUiRendererReady = false;
|
|
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 runBusctl(bus, args) {
|
|
try {
|
|
const { spawnSync } = require2("child_process");
|
|
const res = spawnSync("busctl", [bus === "system" ? "--system" : "--user", ...args], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
return {
|
|
ok: res.status === 0,
|
|
stdout: res.stdout?.trim?.() || "",
|
|
stderr: res.stderr?.trim?.() || "",
|
|
exitCode: res.status ?? -1
|
|
};
|
|
} catch (error) {
|
|
return { ok: false, stdout: "", stderr: String(error), exitCode: -1 };
|
|
}
|
|
}
|
|
function listPipeWireAudioSources() {
|
|
const { spawnSync } = require2("node:child_process");
|
|
const result = spawnSync("pw-dump", [], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
if (result.status !== 0) {
|
|
return { ok: false, sources: [], error: String(result.stderr || "Could not read PipeWire sources").trim() };
|
|
}
|
|
try {
|
|
const objects = JSON.parse(result.stdout);
|
|
const sources = objects.flatMap((object) => {
|
|
const props = object.info?.props;
|
|
if (!object.id || object.type !== "PipeWire:Interface:Node" || !props)
|
|
return [];
|
|
const mediaClass = String(props["media.class"] || "");
|
|
if (!mediaClass.startsWith("Audio/Source"))
|
|
return [];
|
|
const name = String(props["node.name"] || `pipewire-${object.id}`);
|
|
const description = String(props["node.description"] || name);
|
|
return [{
|
|
id: String(object.id),
|
|
name,
|
|
description,
|
|
monitor: String(props["device.class"] || "") === "monitor" || name.endsWith(".monitor") || description.toLowerCase().startsWith("monitor of ")
|
|
}];
|
|
});
|
|
return { ok: true, sources };
|
|
} catch (error) {
|
|
return { ok: false, sources: [], error: `Could not parse PipeWire source list: ${String(error)}` };
|
|
}
|
|
}
|
|
function broadcastAudioState(state) {
|
|
if (mainWindow && !mainWindow.isDestroyed())
|
|
mainWindow.webContents.send("wo:audio-state", state);
|
|
}
|
|
function startPipeWireAudioCapture(source) {
|
|
if (audioCapture)
|
|
audioCapture.kill();
|
|
const { spawn } = require2("node:child_process");
|
|
const child = spawn("pw-cat", [
|
|
"--record",
|
|
"--target",
|
|
source.id,
|
|
"--format",
|
|
"s16",
|
|
"--rate",
|
|
"48000",
|
|
"--channels",
|
|
"2",
|
|
"-"
|
|
], { stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
audioCapture = child;
|
|
child.stdout?.on("data", (data) => {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send("wo:audio-data", {
|
|
format: "s16le",
|
|
sampleRate: 48000,
|
|
channels: 2,
|
|
data,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
});
|
|
child.stderr?.on("data", (data) => debugLog("[Wo] pw-cat:", data.toString().trim()));
|
|
child.once("close", (code) => {
|
|
if (audioCapture === child) {
|
|
audioCapture = null;
|
|
broadcastAudioState({ active: false, ...code && code !== 0 ? { error: `pw-cat exited (${code})` } : {} });
|
|
}
|
|
});
|
|
return new Promise((resolve) => {
|
|
child.once("error", (error) => {
|
|
if (audioCapture === child)
|
|
audioCapture = null;
|
|
const message = error.message;
|
|
broadcastAudioState({ active: false, error: message });
|
|
resolve({ ok: false, error: message });
|
|
});
|
|
child.once("spawn", () => {
|
|
broadcastAudioState({ active: true });
|
|
resolve({ ok: true, source });
|
|
});
|
|
});
|
|
}
|
|
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 sendForwardedPointer(windowName, x, y) {
|
|
if (!ipcSocket || !ipcConnected)
|
|
return;
|
|
const nameBuf = getEncodedWindowName(windowName);
|
|
const msg = Buffer.allocUnsafe(8 + nameBuf.length + 16);
|
|
let off = 0;
|
|
msg.writeUInt32LE(MAGIC.FORWARD_POINTER, off);
|
|
off += 4;
|
|
msg.writeUInt32LE(nameBuf.length, off);
|
|
off += 4;
|
|
nameBuf.copy(msg, off);
|
|
off += nameBuf.length;
|
|
msg.writeDoubleLE(x, off);
|
|
off += 8;
|
|
msg.writeDoubleLE(y, off);
|
|
ipcSocket.write(msg);
|
|
}
|
|
function sendForwardedPointerButton(windowName, x, y, button, pressed, time) {
|
|
if (!ipcSocket || !ipcConnected)
|
|
return;
|
|
const nameBuf = getEncodedWindowName(windowName);
|
|
const msg = Buffer.allocUnsafe(8 + nameBuf.length + 28);
|
|
let off = 0;
|
|
msg.writeUInt32LE(MAGIC.FORWARD_POINTER_BUTTON, off);
|
|
off += 4;
|
|
msg.writeUInt32LE(nameBuf.length, off);
|
|
off += 4;
|
|
nameBuf.copy(msg, off);
|
|
off += nameBuf.length;
|
|
msg.writeDoubleLE(x, off);
|
|
off += 8;
|
|
msg.writeDoubleLE(y, off);
|
|
off += 8;
|
|
msg.writeUInt32LE(button >>> 0, off);
|
|
off += 4;
|
|
msg.writeUInt32LE(pressed ? 1 : 0, off);
|
|
off += 4;
|
|
msg.writeUInt32LE(Math.trunc(time) >>> 0, off);
|
|
ipcSocket.write(msg);
|
|
}
|
|
function sendForwardedPointerScroll(windowName, x, y, dx, dy, time) {
|
|
if (!ipcSocket || !ipcConnected)
|
|
return;
|
|
const nameBuf = getEncodedWindowName(windowName);
|
|
const msg = Buffer.allocUnsafe(8 + nameBuf.length + 36);
|
|
let off = 0;
|
|
msg.writeUInt32LE(MAGIC.FORWARD_POINTER_SCROLL, off);
|
|
off += 4;
|
|
msg.writeUInt32LE(nameBuf.length, off);
|
|
off += 4;
|
|
nameBuf.copy(msg, off);
|
|
off += nameBuf.length;
|
|
msg.writeDoubleLE(x, off);
|
|
off += 8;
|
|
msg.writeDoubleLE(y, off);
|
|
off += 8;
|
|
msg.writeDoubleLE(dx, off);
|
|
off += 8;
|
|
msg.writeDoubleLE(dy, off);
|
|
off += 8;
|
|
msg.writeUInt32LE(Math.trunc(time) >>> 0, off);
|
|
ipcSocket.write(msg);
|
|
}
|
|
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, () => {
|
|
ipcSocket.setNoDelay(true);
|
|
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(initialWindowWidth, offset);
|
|
offset += 4;
|
|
messageBuf.writeUInt32LE(initialWindowHeight, offset);
|
|
ipcSocket.write(messageBuf);
|
|
ipcConnected = true;
|
|
debugLog("[Wo] IPC connected, sent HELLO message");
|
|
sendSiteHashesToCompositor();
|
|
try {
|
|
const nativePath = resolvePath(__dirname2, "../native/build/Release/wo_dmabuf.node");
|
|
nativeDmabuf = loadNativeDmabufModule(require2, nativePath);
|
|
const renderNode = process.env.WO_DRM_RENDER_NODE || "/dev/dri/renderD128";
|
|
debugLog("[Wo] Initializing GBM on render node:", renderNode);
|
|
nativeDmabuf.init(renderNode);
|
|
dmabufSender = new WoDmabufSender(ipcSocket, name, nativeDmabuf);
|
|
const socketFd = ipcSocket?._handle?.fd;
|
|
if (typeof socketFd === "number" && socketFd >= 0 && nativeDmabuf.startFdReader) {
|
|
ipcSocket.pause();
|
|
fdReaderSocketFd = socketFd;
|
|
nativeDmabuf.startFdReader(socketFd, (chunk, fds, closed) => {
|
|
receivedSocketFds.push(...fds);
|
|
if (chunk.length > 0)
|
|
onIpcData(chunk);
|
|
if (closed)
|
|
ipcSocket?.destroy();
|
|
});
|
|
} else {
|
|
ipcSocket.on("data", onIpcData);
|
|
}
|
|
debugLog("[Wo] DMABUF sender initialized");
|
|
} catch (error) {
|
|
debugLog("[Wo] DMABUF sender unavailable:", error);
|
|
dmabufSender = null;
|
|
ipcSocket.on("data", onIpcData);
|
|
}
|
|
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;
|
|
}
|
|
};
|
|
const onIpcData = (chunk) => {
|
|
ipcDataCalls++;
|
|
const now = Date.now();
|
|
if (now - ipcDataLastLog > 2000) {
|
|
ipcDataCalls = 0;
|
|
ipcDataLastLog = now;
|
|
}
|
|
const dataT0 = Date.now();
|
|
rxAppend(chunk);
|
|
while (rxAvailable() >= 4) {
|
|
const magic = compositorRxBuffer.readUInt32LE(rxReadOffset);
|
|
if (magic === MAGIC.FRAME_ACK) {
|
|
if (rxAvailable() < 12)
|
|
break;
|
|
const seq = compositorRxBuffer.readBigUInt64LE(rxReadOffset + 4);
|
|
dmabufSender?.acknowledge(seq);
|
|
const wasBackpressured = inFlightFrameSeqs.size >= MAX_IN_FLIGHT_FRAMES;
|
|
inFlightFrameSeqs.delete(seq.toString());
|
|
rxConsume(12);
|
|
if (wasBackpressured && mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.invalidate();
|
|
}
|
|
continue;
|
|
}
|
|
if (magic === MAGIC.MOUSE_MOVE) {
|
|
if (rxAvailable() < 20)
|
|
break;
|
|
const x = compositorRxBuffer.readDoubleLE(rxReadOffset + 4);
|
|
const y = compositorRxBuffer.readDoubleLE(rxReadOffset + 12);
|
|
rxConsume(20);
|
|
pointerX = Math.round(x);
|
|
pointerY = Math.round(y);
|
|
pendingMouseMove = { x: pointerX, y: pointerY };
|
|
if (!inputFlushTimer)
|
|
inputFlushTimer = setImmediate(flushInputEvents);
|
|
continue;
|
|
}
|
|
if (magic === MAGIC.MOUSE_BUTTON) {
|
|
if (rxAvailable() < 16)
|
|
break;
|
|
const button = compositorRxBuffer.readUInt32LE(rxReadOffset + 4);
|
|
const pressed = compositorRxBuffer.readUInt32LE(rxReadOffset + 8) !== 0;
|
|
rxConsume(16);
|
|
if (pendingMouseMove && mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.sendInputEvent({
|
|
type: "mouseMove",
|
|
x: pendingMouseMove.x,
|
|
y: pendingMouseMove.y
|
|
});
|
|
pendingMouseMove = null;
|
|
}
|
|
if (inputFlushTimer) {
|
|
clearImmediate(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 (rxAvailable() < 16)
|
|
break;
|
|
const key = compositorRxBuffer.readUInt32LE(rxReadOffset + 4);
|
|
const pressed = compositorRxBuffer.readUInt32LE(rxReadOffset + 8) !== 0;
|
|
rxConsume(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 (rxAvailable() < 16)
|
|
break;
|
|
const vertical = compositorRxBuffer.readInt32LE(rxReadOffset + 4);
|
|
const horizontal = compositorRxBuffer.readInt32LE(rxReadOffset + 8);
|
|
rxConsume(16);
|
|
if (pendingMouseMove && mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.sendInputEvent({ type: "mouseMove", x: pendingMouseMove.x, y: pendingMouseMove.y });
|
|
pendingMouseMove = null;
|
|
}
|
|
if (inputFlushTimer) {
|
|
clearImmediate(inputFlushTimer);
|
|
inputFlushTimer = null;
|
|
}
|
|
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 (rxAvailable() < 12)
|
|
break;
|
|
const nameLen = compositorRxBuffer.readUInt32LE(rxReadOffset + 4);
|
|
if (rxAvailable() < 12 + nameLen)
|
|
break;
|
|
const focused = compositorRxBuffer.readUInt32LE(rxReadOffset + 8) !== 0;
|
|
const windowName = compositorRxBuffer.slice(rxReadOffset + 12, rxReadOffset + 12 + nameLen).toString("utf8");
|
|
rxConsume(12 + nameLen);
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send("wo:focus-change", { window: windowName, focused });
|
|
}
|
|
continue;
|
|
}
|
|
if (magic === MAGIC.WINDOW_META) {
|
|
if (rxAvailable() < 8)
|
|
break;
|
|
const payloadLen = compositorRxBuffer.readUInt32LE(rxReadOffset + 4);
|
|
if (rxAvailable() < 8 + payloadLen)
|
|
break;
|
|
const metadata = compositorRxBuffer.slice(rxReadOffset + 8, rxReadOffset + 8 + payloadLen).toString("utf8");
|
|
rxConsume(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)) {
|
|
const currentNames = new Set(parsed.windows.map((w) => w.name).filter(Boolean));
|
|
for (const cachedName of surfaceSabCache.keys()) {
|
|
if (!currentNames.has(cachedName)) {
|
|
surfaceSabCache.delete(cachedName);
|
|
}
|
|
}
|
|
for (const cachedName of shmFdCache.keys()) {
|
|
if (!currentNames.has(cachedName)) {
|
|
const entry = shmFdCache.get(cachedName);
|
|
if (entry) {
|
|
try {
|
|
fs.closeSync(entry.extFd);
|
|
} catch {}
|
|
}
|
|
shmFdCache.delete(cachedName);
|
|
}
|
|
}
|
|
compositorWindows = parsed.windows;
|
|
mainWindow.webContents.send("wo:windows", compositorWindows);
|
|
if (Array.isArray(parsed.monitors)) {
|
|
mainWindow.webContents.send("wo:monitors", parsed.monitors);
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
continue;
|
|
}
|
|
if (magic === MAGIC.SHM_BUFFER) {
|
|
if (rxAvailable() < 8)
|
|
break;
|
|
const nameLen = compositorRxBuffer.readUInt32LE(rxReadOffset + 4);
|
|
const baseHeader = 8 + nameLen + 20 + 4;
|
|
if (rxAvailable() < baseHeader)
|
|
break;
|
|
let off = rxReadOffset + 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);
|
|
off += 4;
|
|
const numRects = compositorRxBuffer.readUInt32LE(off);
|
|
off += 4;
|
|
const fullHeader = baseHeader + numRects * 16;
|
|
if (rxAvailable() < 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;
|
|
}
|
|
rxConsume(fullHeader);
|
|
try {
|
|
const extFd = getOrOpenShmFd(windowName, pid, fd);
|
|
const fullSize = sbStride * sbHeight;
|
|
const sabEntry = getOrCreateSab(windowName, sbWidth, sbHeight, sbStride);
|
|
const hasUsableRects = damageRects.length > 0 && damageRects.length < 64;
|
|
if (hasUsableRects && nativeDmabuf?.copyMmapDamageToSab) {
|
|
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) {
|
|
nativeDmabuf.copyMmapDamageToSab(extFd, sabEntry.sab, sbStride, [
|
|
{ y: minY, h: maxY - minY }
|
|
]);
|
|
}
|
|
} else if (nativeDmabuf?.copyMmapToSab) {
|
|
nativeDmabuf.copyMmapToSab(extFd, sabEntry.sab, fullSize);
|
|
} else {
|
|
const tmpBuf = Buffer.alloc(fullSize);
|
|
let totalRead = 0;
|
|
while (totalRead < fullSize) {
|
|
const bytesRead = fs.readSync(extFd, tmpBuf, totalRead, fullSize - totalRead, totalRead);
|
|
if (bytesRead <= 0)
|
|
break;
|
|
totalRead += bytesRead;
|
|
}
|
|
new Uint8Array(sabEntry.sab).set(new Uint8Array(tmpBuf.buffer, tmpBuf.byteOffset, tmpBuf.byteLength));
|
|
}
|
|
surfaceUpdatePending.set(windowName, {
|
|
name: windowName,
|
|
width: sbWidth,
|
|
height: sbHeight,
|
|
stride: sbStride,
|
|
generation: ++surfaceUpdateGeneration,
|
|
damageRects: hasUsableRects ? damageRects : undefined
|
|
});
|
|
if (!surfaceUpdateFlushScheduled) {
|
|
surfaceUpdateFlushScheduled = true;
|
|
setImmediate(flushSurfaceUpdates);
|
|
}
|
|
} 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 (rxAvailable() < 8)
|
|
break;
|
|
const nameLen = compositorRxBuffer.readUInt32LE(rxReadOffset + 4);
|
|
const numPlanesOffset = 8 + nameLen + 12;
|
|
if (rxAvailable() < numPlanesOffset + 4)
|
|
break;
|
|
let off = rxReadOffset + 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 planeSectionSize = numPlanes * 24;
|
|
const totalSize = off - rxReadOffset + planeSectionSize;
|
|
if (rxAvailable() < totalSize)
|
|
break;
|
|
const planes = [];
|
|
let planeOff = off;
|
|
for (let i = 0;i < numPlanes; i++) {
|
|
planeOff += 4;
|
|
const planeOffset = compositorRxBuffer.readBigUInt64LE(planeOff);
|
|
planeOff += 8;
|
|
const planeStride = compositorRxBuffer.readUInt32LE(planeOff);
|
|
planeOff += 4;
|
|
const modifier = compositorRxBuffer.readBigUInt64LE(planeOff);
|
|
planeOff += 8;
|
|
planes.push({ offset: planeOffset, stride: planeStride, modifier });
|
|
}
|
|
try {
|
|
if (nativeDmabuf && ipcSocket) {
|
|
const socketFd = ipcSocket?._handle?.fd;
|
|
if (typeof socketFd === "number" && socketFd >= 0) {
|
|
const planeFds = receivedSocketFds.splice(0, numPlanes);
|
|
const primaryFd = planeFds.find((fd) => fd >= 0);
|
|
const primaryPlane = planes[0];
|
|
const stride = primaryPlane?.stride || dmabufW * 4;
|
|
const offset = primaryPlane ? Number(primaryPlane.offset) : 0;
|
|
if (primaryFd !== undefined) {
|
|
try {
|
|
const sabEntry = getOrCreateSab(dmabufName, dmabufW, dmabufH, stride);
|
|
const copySize = offset + stride * dmabufH;
|
|
nativeDmabuf.copyMmapToSab(primaryFd, sabEntry.sab, copySize, offset);
|
|
surfaceUpdatePending.set(dmabufName, {
|
|
name: dmabufName,
|
|
width: dmabufW,
|
|
height: dmabufH,
|
|
stride,
|
|
generation: ++surfaceUpdateGeneration
|
|
});
|
|
if (!surfaceUpdateFlushScheduled) {
|
|
surfaceUpdateFlushScheduled = true;
|
|
setImmediate(flushSurfaceUpdates);
|
|
}
|
|
const textureInfo = nativeDmabuf.importDmabufTexture(dmabufName, primaryFd, 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 {
|
|
for (const fd of planeFds) {
|
|
try {
|
|
fs.closeSync(fd);
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
debugLog(`[Wo] DMABUF import failed for ${dmabufName}:`, err);
|
|
}
|
|
rxConsume(totalSize);
|
|
continue;
|
|
}
|
|
if (magic === MAGIC.POINTER_LOCK_REQUEST) {
|
|
if (rxAvailable() < 12)
|
|
break;
|
|
const nameLen = compositorRxBuffer.readUInt32LE(rxReadOffset + 4);
|
|
if (rxAvailable() < 12 + nameLen)
|
|
break;
|
|
const lock = compositorRxBuffer.readUInt32LE(rxReadOffset + 8) !== 0;
|
|
const windowName = compositorRxBuffer.slice(rxReadOffset + 12, rxReadOffset + 12 + nameLen).toString("utf8");
|
|
rxConsume(12 + nameLen);
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send("wo:pointer-lock-request", { window: windowName, lock });
|
|
}
|
|
continue;
|
|
}
|
|
if (magic === MAGIC.ENV_UPDATE) {
|
|
if (rxAvailable() < 8)
|
|
break;
|
|
const jsonLen = compositorRxBuffer.readUInt32LE(rxReadOffset + 4);
|
|
if (rxAvailable() < 8 + jsonLen)
|
|
break;
|
|
const jsonStr = compositorRxBuffer.slice(rxReadOffset + 8, rxReadOffset + 8 + jsonLen).toString("utf8");
|
|
rxConsume(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;
|
|
}
|
|
if (magic === MAGIC.SCREENCOPY_EVENT) {
|
|
if (rxAvailable() < 12)
|
|
break;
|
|
const active = compositorRxBuffer.readUInt32LE(rxReadOffset + 4) !== 0;
|
|
const clientCount = compositorRxBuffer.readUInt32LE(rxReadOffset + 8);
|
|
rxConsume(12);
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send("wo:screencopy-event", { active, clientCount });
|
|
}
|
|
continue;
|
|
}
|
|
if (magic === MAGIC.WINDOW_SIZE) {
|
|
if (rxAvailable() < 12)
|
|
break;
|
|
const width = compositorRxBuffer.readUInt32LE(rxReadOffset + 4);
|
|
const height = compositorRxBuffer.readUInt32LE(rxReadOffset + 8);
|
|
rxConsume(12);
|
|
if (mainWindow && !mainWindow.isDestroyed() && width > 0 && height > 0) {
|
|
mainWindow.setSize(width, height, false);
|
|
}
|
|
continue;
|
|
}
|
|
debugLog("[Wo] UNKNOWN magic 0x" + magic.toString(16).padStart(8, "0") + " bufLen=" + rxAvailable() + " — stream may be corrupted");
|
|
rxConsume(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;
|
|
if (fdReaderSocketFd !== null) {
|
|
nativeDmabuf?.stopFdReader?.(fdReaderSocketFd);
|
|
fdReaderSocketFd = null;
|
|
}
|
|
for (const fd of receivedSocketFds.splice(0)) {
|
|
try {
|
|
fs.closeSync(fd);
|
|
} catch {}
|
|
}
|
|
ipcSocket = null;
|
|
dmabufSender?.releaseAll();
|
|
dmabufSender = null;
|
|
inFlightFrameSeqs.clear();
|
|
compositorRxBuffer = Buffer.alloc(64 * 1024);
|
|
rxWriteOffset = 0;
|
|
rxReadOffset = 0;
|
|
surfaceSabCache.clear();
|
|
surfaceUpdatePending.clear();
|
|
closeShmFdCache();
|
|
scheduleIpcReconnect();
|
|
});
|
|
setTimeout(() => {
|
|
if (!ipcConnected) {
|
|
debugLog("[Wo] IPC connection timeout");
|
|
reject(new Error("Failed to connect to compositor IPC socket"));
|
|
}
|
|
}, 5000);
|
|
});
|
|
}
|
|
function sendSiteHashesToCompositor() {
|
|
if (!ipcSocket || !ipcConnected || !activeSiteHashes)
|
|
return;
|
|
const windowName = stringToBuffer(name);
|
|
const payload = Buffer.from(JSON.stringify(activeSiteHashes));
|
|
if (payload.length > 24 * 1024 * 1024)
|
|
return;
|
|
const message = Buffer.allocUnsafe(12 + windowName.length + payload.length);
|
|
let offset = 0;
|
|
message.writeUInt32LE(MAGIC.SITE_HASHES, offset);
|
|
offset += 4;
|
|
message.writeUInt32LE(windowName.length, offset);
|
|
offset += 4;
|
|
message.writeUInt32LE(payload.length, offset);
|
|
offset += 4;
|
|
windowName.copy(message, offset);
|
|
offset += windowName.length;
|
|
payload.copy(message, offset);
|
|
ipcSocket.write(message);
|
|
}
|
|
function setupIpcHandlers() {
|
|
ipcMain.handle("wo:performance-status", () => getPerformanceStatus());
|
|
ipcMain.handle("wo:settings", async (event, operation, key, value) => {
|
|
if (!senderHasCapability(event.sender, "settings"))
|
|
return permissionDenied("settings");
|
|
if (typeof operation !== "string" || !["get", "set", "remove", "keys", "getSecret", "setSecret", "removeSecret"].includes(operation)) {
|
|
return { ok: false, error: "Unknown settings operation" };
|
|
}
|
|
if (operation !== "keys" && (typeof key !== "string" || !SETTINGS_KEY_PATTERN.test(key) || ["__proto__", "prototype", "constructor"].includes(key))) {
|
|
return { ok: false, error: "Setting keys must be 1-128 letters, digits, dots, underscores, colons, or hyphens" };
|
|
}
|
|
let origin;
|
|
try {
|
|
origin = new URL(event.sender.getURL()).origin;
|
|
} catch {
|
|
return { ok: false, error: "Settings require a valid web origin" };
|
|
}
|
|
try {
|
|
if (operation === "get") {
|
|
const settings = await readSiteSettings(origin);
|
|
return settings[key];
|
|
}
|
|
if (operation === "keys")
|
|
return Object.keys(await readSiteSettings(origin)).sort();
|
|
if (operation === "getSecret")
|
|
return (await readSiteSecrets(origin))[key];
|
|
if (operation === "removeSecret") {
|
|
await updateSiteSecrets(origin, (secrets) => {
|
|
delete secrets[key];
|
|
});
|
|
return { ok: true };
|
|
}
|
|
if (operation === "setSecret") {
|
|
if (typeof value !== "string" || Buffer.byteLength(value) > MAX_SITE_SETTINGS_BYTES) {
|
|
return { ok: false, error: "Secret values must be text and at most 64 KiB" };
|
|
}
|
|
await updateSiteSecrets(origin, (secrets) => {
|
|
secrets[key] = value;
|
|
});
|
|
return { ok: true };
|
|
}
|
|
if (operation === "remove") {
|
|
await updateSiteSettings(origin, (settings) => {
|
|
delete settings[key];
|
|
});
|
|
return { ok: true };
|
|
}
|
|
const encoded = JSON.stringify(value);
|
|
if (encoded === undefined || Buffer.byteLength(encoded) > MAX_SITE_SETTINGS_BYTES) {
|
|
return { ok: false, error: "Settings values must be JSON and at most 64 KiB" };
|
|
}
|
|
await updateSiteSettings(origin, (settings) => {
|
|
settings[key] = JSON.parse(encoded);
|
|
});
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, error: String(error) };
|
|
}
|
|
});
|
|
ipcMain.on("wo:portal-ui-ready", (event) => {
|
|
if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents)
|
|
return;
|
|
portalUiRendererReady = true;
|
|
portalUiRendererGeneration += 1;
|
|
flushPortalUiEvents();
|
|
});
|
|
ipcMain.on("wo:portal-ui-delivered", (event, requestId) => {
|
|
if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents || typeof requestId !== "string")
|
|
return;
|
|
portalUiEvents.delete(requestId);
|
|
});
|
|
ipcMain.handle("wo:resource-consent-respond", (event, requestId, token, decision) => {
|
|
if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents || typeof requestId !== "string" || typeof token !== "string" || !["deny", "allow", "allow-all"].includes(decision)) {
|
|
debugLog("[Wo] Rejected malformed resource-consent IPC response");
|
|
return { ok: false };
|
|
}
|
|
const ok = resolveResourceConsent(requestId, token, decision);
|
|
debugLog(`[Wo] Resource-consent IPC response received: accepted=${ok} decision=${decision}`);
|
|
return { ok };
|
|
});
|
|
ipcMain.handle("wo:portal-ui-respond", (event, requestId, response) => {
|
|
if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents) {
|
|
return { ok: false, error: "Portal UI response is only available to the compositor shell" };
|
|
}
|
|
if (typeof requestId !== "string" || !requestId) {
|
|
return { ok: false, error: "Missing portal requestId" };
|
|
}
|
|
return respondToPortalRequest(requestId, { response });
|
|
});
|
|
ipcMain.handle("wo:portal-context-menu", async (event, options) => {
|
|
if (!senderHasCapability(event.sender, "portals"))
|
|
return permissionDenied("portals");
|
|
if (!mainWindow || mainWindow.isDestroyed() || !Array.isArray(options?.items) || options.items.length === 0 || options.items.length > 100) {
|
|
return { ok: false, error: "A context menu requires between 1 and 100 items" };
|
|
}
|
|
const selected = new Map;
|
|
const template = [];
|
|
for (const [index, raw] of options.items.entries()) {
|
|
const type = raw.type === "separator" ? "separator" : raw.type === "checkbox" || raw.type === "radio" ? raw.type : "normal";
|
|
const id = typeof raw.id === "string" ? raw.id.slice(0, 128) : String(index);
|
|
if (type !== "separator" && (typeof raw.label !== "string" || raw.label.length > 256)) {
|
|
return { ok: false, error: `Invalid context-menu label at item ${index}` };
|
|
}
|
|
const item = type === "separator" ? { type: "separator" } : {
|
|
id,
|
|
label: String(raw.label),
|
|
type,
|
|
enabled: raw.enabled !== false,
|
|
checked: raw.checked === true,
|
|
...typeof raw.accelerator === "string" ? { accelerator: raw.accelerator.slice(0, 64) } : {},
|
|
click: () => {
|
|
selected.set(0, id);
|
|
}
|
|
};
|
|
template.push(item);
|
|
}
|
|
const menu = Menu.buildFromTemplate(template);
|
|
return await new Promise((resolve) => {
|
|
let settled = false;
|
|
const finish = () => {
|
|
if (settled)
|
|
return;
|
|
settled = true;
|
|
const selectedId = selected.get(0);
|
|
resolve(selectedId === undefined ? { ok: true } : { ok: true, selectedId });
|
|
};
|
|
menu.popup({
|
|
window: mainWindow,
|
|
x: Number.isFinite(options.x) ? Math.trunc(options.x) : undefined,
|
|
y: Number.isFinite(options.y) ? Math.trunc(options.y) : undefined,
|
|
callback: finish
|
|
});
|
|
});
|
|
});
|
|
ipcMain.handle("action", (event, action, payload) => {
|
|
if (!senderHasCapability(event.sender, "window-management"))
|
|
return permissionDenied("window-management");
|
|
return sendActionToCompositor(action, payload);
|
|
});
|
|
ipcMain.on("action-sync", (event, action, payload) => {
|
|
if (!senderHasCapability(event.sender, "window-management"))
|
|
return;
|
|
sendActionToCompositor(action, payload);
|
|
});
|
|
ipcMain.on("wo:forward-pointer", (event, windowName, x, y) => {
|
|
if (!senderHasCapability(event.sender, "window-management"))
|
|
return;
|
|
sendForwardedPointer(windowName, x, y);
|
|
});
|
|
ipcMain.on("wo:forward-pointer-button", (event, windowName, x, y, button, pressed, time) => {
|
|
if (!senderHasCapability(event.sender, "window-management"))
|
|
return;
|
|
sendForwardedPointerButton(windowName, x, y, button, pressed, time);
|
|
});
|
|
ipcMain.on("wo:forward-pointer-scroll", (event, windowName, x, y, dx, dy, time) => {
|
|
if (!senderHasCapability(event.sender, "window-management"))
|
|
return;
|
|
sendForwardedPointerScroll(windowName, x, y, dx, dy, time);
|
|
});
|
|
ipcMain.on("wo:forward-pointer-leave", (event, windowName) => {
|
|
if (!senderHasCapability(event.sender, "window-management"))
|
|
return;
|
|
if (!ipcSocket || !ipcConnected)
|
|
return;
|
|
const nameBuf = Buffer.from(windowName, "utf8");
|
|
const msg = Buffer.allocUnsafe(8 + nameBuf.length);
|
|
msg.writeUInt32LE(MAGIC.FORWARD_POINTER_LEAVE, 0);
|
|
msg.writeUInt32LE(nameBuf.length, 4);
|
|
nameBuf.copy(msg, 8);
|
|
ipcSocket.write(msg);
|
|
});
|
|
ipcMain.handle("wo:get-windows", async (event) => senderHasCapability(event.sender, "window-management") ? compositorWindows.length > 0 ? compositorWindows : clientWindows : permissionDenied("window-management"));
|
|
ipcMain.on("wo:keybind-action", (event, action) => {
|
|
if (!senderHasCapability(event.sender, "window-management"))
|
|
return;
|
|
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 (!senderHasCapability(event.sender, "overlay-rendering"))
|
|
return permissionDenied("overlay-rendering");
|
|
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 (!senderHasCapability(event.sender, "window-management"))
|
|
return;
|
|
if (ipcSocket && ipcConnected) {
|
|
const nameBuf = getEncodedWindowName(windowName);
|
|
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(Math.trunc(time) >>> 0, off);
|
|
ipcSocket.write(msg);
|
|
}
|
|
});
|
|
ipcMain.on("wo:forward-relative-pointer", (event, windowName, dx, dy) => {
|
|
if (!senderHasCapability(event.sender, "window-management"))
|
|
return;
|
|
if (ipcSocket && ipcConnected) {
|
|
const nameBuf = getEncodedWindowName(windowName);
|
|
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);
|
|
}
|
|
});
|
|
ipcMain.handle("wo:syscall", async (event, type, params) => {
|
|
const capability = syscallCapability(type);
|
|
if (!capability || !senderHasCapability(event.sender, capability))
|
|
return permissionDenied(capability || "window-management");
|
|
try {
|
|
switch (type) {
|
|
case "list_applications": {
|
|
return listDesktopApplications({ locale: typeof params.locale === "string" ? params.locale : undefined });
|
|
}
|
|
case "launch_desktop": {
|
|
const desktopId = String(params.desktopId || "");
|
|
const files = Array.isArray(params.files) ? params.files.map(String) : [];
|
|
return launchDesktopApplication(desktopId, files);
|
|
}
|
|
case "xdg_directories": {
|
|
return { ok: true, directories: getXdgDirectories() };
|
|
}
|
|
case "resolve_icon": {
|
|
const iconName = String(params.name || "");
|
|
if (!iconName)
|
|
return null;
|
|
return resolveDesktopIcon(iconName, {
|
|
size: typeof params.size === "number" ? params.size : undefined,
|
|
theme: typeof params.theme === "string" ? params.theme : undefined
|
|
}) ?? null;
|
|
}
|
|
case "launch": {
|
|
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 "exec": {
|
|
const { execFile } = await import("child_process");
|
|
const command = typeof params.command === "string" ? params.command : "";
|
|
const args = Array.isArray(params.args) ? params.args.filter((arg) => typeof arg === "string").slice(0, 64) : [];
|
|
if (!command || args.length !== (Array.isArray(params.args) ? params.args.length : 0)) {
|
|
return { ok: false, error: "Command and string arguments are required" };
|
|
}
|
|
return await new Promise((resolve) => {
|
|
execFile(command, args, { encoding: "utf8", timeout: 15000, maxBuffer: 1048576 }, (error, stdout, stderr) => {
|
|
const exitCode = typeof error?.code === "number" ? error.code : error ? 1 : 0;
|
|
resolve({
|
|
ok: !error,
|
|
stdout: String(stdout || ""),
|
|
stderr: String(stderr || ""),
|
|
exitCode,
|
|
...error ? { 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 "portal_respond": {
|
|
const requestId = String(params.requestId || "");
|
|
if (!requestId) {
|
|
return { ok: false, error: "Missing portal requestId" };
|
|
}
|
|
return respondToPortalRequest(requestId, {
|
|
response: params.response,
|
|
allowed: typeof params.allowed === "boolean" ? params.allowed : undefined,
|
|
type: typeof params.type === "string" ? params.type : undefined,
|
|
windowName: params.windowName
|
|
});
|
|
}
|
|
case "dbus_call": {
|
|
const bus = params.bus === "system" ? "system" : "user";
|
|
const service = String(params.service || "");
|
|
const objectPath = String(params.objectPath || "");
|
|
const iface = String(params.interface || params.iface || "");
|
|
const method = String(params.method || "");
|
|
const signature = typeof params.signature === "string" ? params.signature : "";
|
|
const argList = Array.isArray(params.args) ? params.args : [];
|
|
if (!service || !objectPath || !iface || !method) {
|
|
return { ok: false, error: "Missing dbus parameters" };
|
|
}
|
|
const args = ["call", service, objectPath, iface, method, signature || ""];
|
|
if (signature) {
|
|
args.push(...argList.map((v) => String(v)));
|
|
}
|
|
const result = runBusctl(bus, args);
|
|
return result;
|
|
}
|
|
case "dbus_get_property": {
|
|
const bus = params.bus === "system" ? "system" : "user";
|
|
const service = String(params.service || "");
|
|
const objectPath = String(params.objectPath || "");
|
|
const iface = String(params.interface || params.iface || "");
|
|
const prop = String(params.property || params.prop || "");
|
|
if (!service || !objectPath || !iface || !prop) {
|
|
return { ok: false, error: "Missing dbus parameters" };
|
|
}
|
|
const result = runBusctl(bus, ["get-property", service, objectPath, iface, prop]);
|
|
return result;
|
|
}
|
|
case "dbus_get_all_properties": {
|
|
const bus = params.bus === "system" ? "system" : "user";
|
|
const service = String(params.service || "");
|
|
const objectPath = String(params.objectPath || "");
|
|
const iface = String(params.interface || params.iface || "");
|
|
if (!service || !objectPath || !iface)
|
|
return { ok: false, error: "Missing D-Bus parameters" };
|
|
return runBusctl(bus, ["call", service, objectPath, "org.freedesktop.DBus.Properties", "GetAll", "s", iface]);
|
|
}
|
|
case "dbus_list_names": {
|
|
const bus = params.bus === "system" ? "system" : "user";
|
|
const result = runBusctl(bus, ["--no-pager", "list"]);
|
|
const names = result.stdout.split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/, 1)[0]).filter((name) => name && name !== "");
|
|
return { ...result, names };
|
|
}
|
|
case "audio_sources": {
|
|
const result = listPipeWireAudioSources();
|
|
return result;
|
|
}
|
|
case "audio_capture_start": {
|
|
const result = listPipeWireAudioSources();
|
|
if (!result.ok)
|
|
return { ok: false, error: result.error };
|
|
const requestedId = typeof params.sourceId === "string" ? params.sourceId : "";
|
|
const source = requestedId ? result.sources.find((entry) => entry.id === requestedId) : result.sources.find((entry) => entry.monitor);
|
|
if (!source)
|
|
return { ok: false, error: requestedId ? "PipeWire source not found" : "No desktop monitor source is available" };
|
|
return startPipeWireAudioCapture(source);
|
|
}
|
|
case "audio_capture_stop": {
|
|
const child = audioCapture;
|
|
audioCapture = null;
|
|
if (child && !child.killed)
|
|
child.kill();
|
|
broadcastAudioState({ active: false });
|
|
return { ok: true };
|
|
}
|
|
case "load_local_file": {
|
|
const filePath = String(params.path || "");
|
|
if (!filePath) {
|
|
return { ok: false, error: "Missing path" };
|
|
}
|
|
if (!fs.existsSync(filePath)) {
|
|
return { ok: false, error: `Path does not exist: ${filePath}` };
|
|
}
|
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
return { ok: false, error: "Main window unavailable" };
|
|
}
|
|
try {
|
|
debugLog("[Wo] Loading local file:", filePath);
|
|
await mainWindow.loadFile(filePath);
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, error: String(error) };
|
|
}
|
|
}
|
|
default:
|
|
return { ok: false, error: `Unknown syscall: ${type}` };
|
|
}
|
|
} catch (error) {
|
|
return { ok: false, error: `Syscall error: ${String(error)}` };
|
|
}
|
|
});
|
|
}
|
|
function flushPortalUiEvents() {
|
|
if (!portalUiRendererReady || !mainWindow || mainWindow.isDestroyed())
|
|
return;
|
|
for (const [requestId, event] of portalUiEvents) {
|
|
if (!portalPendingRequests.has(requestId) || event.sentGeneration === portalUiRendererGeneration)
|
|
continue;
|
|
event.sentGeneration = portalUiRendererGeneration;
|
|
mainWindow.webContents.send("wo:portal-request", event.data);
|
|
}
|
|
}
|
|
async function createWindow() {
|
|
debugLog("[Wo] createWindow starting");
|
|
const pageUrl = url || (html ? html.startsWith("file://") ? html : pathToFileURL(resolvePath(html)).href : null) || "data:text/html,<!DOCTYPE html><html><head><style>body{background:white;margin:0;}</style></head><body></body></html>";
|
|
let siteAssets = {
|
|
pageUrl,
|
|
appId: "website",
|
|
capabilities: [],
|
|
styles: "",
|
|
stylesUrl: "",
|
|
stylesHash: "",
|
|
styleBodies: [],
|
|
scripts: [],
|
|
scriptBodies: [],
|
|
indexSha256: "",
|
|
indexBytes: new Uint8Array,
|
|
stylesBytes: new Uint8Array,
|
|
trusted: false
|
|
};
|
|
if (/^(https?:|file:)/.test(pageUrl)) {
|
|
siteAssets = await prefetchWoSiteAssets(pageUrl);
|
|
}
|
|
debugLog(siteAssets.trusted ? `[Wo] Verified site ${siteAssets.appId}; requested capabilities: ${siteAssets.capabilities.join(", ") || "(none)"}` : `[Wo] Site capabilities not granted because verification failed: ${siteAssets.trustError || "no verifiable site manifest"}`);
|
|
apiGrants = new Set;
|
|
apiGrantPageUrl = null;
|
|
verifiedScriptCache.clear();
|
|
lockedScriptUrls = new Map;
|
|
if (siteAssets.trusted && siteAssets.stylesUrl && siteAssets.stylesHash) {
|
|
verifiedScriptCache.set(siteAssets.stylesHash, Buffer.from(siteAssets.stylesBytes));
|
|
lockedScriptUrls.set(siteAssets.stylesUrl, siteAssets.stylesHash);
|
|
}
|
|
for (const script of siteAssets.scriptBodies) {
|
|
verifiedScriptCache.set(script.hash, Buffer.from(script.bytes));
|
|
lockedScriptUrls.set(script.url, script.hash);
|
|
}
|
|
for (const style of siteAssets.styleBodies) {
|
|
verifiedScriptCache.set(style.hash, Buffer.from(style.bytes));
|
|
lockedScriptUrls.set(style.url, style.hash);
|
|
}
|
|
activeSiteHashes = siteAssets.trusted ? {
|
|
pageUrl: siteAssets.pageUrl,
|
|
indexBytes: Array.from(siteAssets.indexBytes),
|
|
stylesBytes: Array.from(siteAssets.stylesBytes)
|
|
} : null;
|
|
sendSiteHashesToCompositor();
|
|
const trustedSystemShell = SYSTEM_SHELL && siteAssets.trusted && siteAssets.appId === "rip.crit.comraw" && isLoopbackHttp(pageUrl);
|
|
if (trustedSystemShell) {
|
|
apiGrants = new Set(siteAssets.capabilities);
|
|
apiGrantPageUrl = siteAssets.pageUrl;
|
|
debugLog("[Wo] Granted verified comraw system-shell capabilities");
|
|
} else if (siteAssets.trusted && siteAssets.capabilities.length > 0) {
|
|
const cachedGrant = await loadApiConsent(siteAssets);
|
|
if (cachedGrant) {
|
|
apiGrants = new Set(cachedGrant);
|
|
apiGrantPageUrl = siteAssets.pageUrl;
|
|
debugLog(`[Wo] Restored remembered API consent for ${siteAssets.appId} (${apiConsentKey(siteAssets).slice(0, 12)})`);
|
|
} else {
|
|
const scriptHashes = siteAssets.scriptBodies.map((script) => script.hash.slice(0, 12)).join(", ") || "(no external scripts)";
|
|
const permissionResult = await dialog.showMessageBox({
|
|
type: "question",
|
|
title: "Wo API permission request",
|
|
message: `${siteAssets.appId} requests access to Wo APIs`,
|
|
detail: `Origin: ${new URL(pageUrl).origin}
|
|
Verified HTML SHA-256: ${siteAssets.indexSha256}
|
|
Verified JavaScript SHA-256: ${scriptHashes}
|
|
|
|
Requested permissions: ${siteAssets.capabilities.join(", ")}
|
|
|
|
Allowing remembers this decision for this origin and these exact HTML/JavaScript assets. Wo will ask again if any of them change.`,
|
|
buttons: ["Allow and remember", "Open without Wo APIs"],
|
|
defaultId: 1,
|
|
cancelId: 1,
|
|
noLink: true
|
|
});
|
|
if (permissionResult.response === 0) {
|
|
apiGrants = new Set(siteAssets.capabilities);
|
|
apiGrantPageUrl = siteAssets.pageUrl;
|
|
try {
|
|
await saveApiConsent(siteAssets, [...apiGrants]);
|
|
debugLog(`[Wo] Remembered API consent for ${siteAssets.appId} (${apiConsentKey(siteAssets).slice(0, 12)})`);
|
|
} catch (error) {
|
|
debugLog("[Wo] Could not persist API consent:", String(error));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const requested = siteAssets.trusted ? siteAssets.capabilities : [];
|
|
const granted = [...apiGrants];
|
|
const preloadPath = joinPath(__dirname2, "preload.js");
|
|
const opaqueDesktopRoot = !CLIENT_MODE && WINDOW_CONFIG.name === "main";
|
|
debugLog("[Wo] preloadPath=" + preloadPath);
|
|
try {
|
|
mainWindow = new BrowserWindow({
|
|
width: CLIENT_MODE ? 1400 : initialWindowWidth,
|
|
height: CLIENT_MODE ? 900 : initialWindowHeight,
|
|
transparent: !opaqueDesktopRoot,
|
|
backgroundColor: opaqueDesktopRoot ? "#0b0d12" : "#00000000",
|
|
frame: CLIENT_MODE,
|
|
webPreferences: {
|
|
sandbox: false,
|
|
contextIsolation: true,
|
|
preload: preloadPath,
|
|
additionalArguments: [
|
|
`--wo-api-requested=${encodeURIComponent(JSON.stringify(requested))}`,
|
|
`--wo-api-granted=${encodeURIComponent(JSON.stringify(granted))}`,
|
|
`--wo-api-page=${encodeURIComponent(apiGrantPageUrl || "")}`,
|
|
...!CLIENT_MODE ? ["--wo-system-portal-ui=1"] : []
|
|
],
|
|
nodeIntegration: false,
|
|
offscreen: CLIENT_MODE ? false : {
|
|
useSharedTexture: offscreenSharedTextureEnabled,
|
|
...offscreenSharedTextureEnabled ? { sharedTexturePixelFormat: "argb" } : {}
|
|
},
|
|
webgl: true,
|
|
backgroundThrottling: false
|
|
},
|
|
show: CLIENT_MODE
|
|
});
|
|
mainPageLoadFinished = false;
|
|
let scriptLockActive = true;
|
|
let resourceApprovalPageUrl = siteAssets.pageUrl;
|
|
let firstNavigation = true;
|
|
let pendingResourceChecks = 0;
|
|
let navigationGeneration = 0;
|
|
let reloadAfterResourceApproval = false;
|
|
let resourceReloadTimer = null;
|
|
const deniedResourceKeys = new Set;
|
|
const finishResourceCheck = (approved) => {
|
|
if (approved)
|
|
reloadAfterResourceApproval = true;
|
|
pendingResourceChecks = Math.max(0, pendingResourceChecks - 1);
|
|
if (pendingResourceChecks !== 0 || !reloadAfterResourceApproval && !resourceConsentUiActive || resourceReloadTimer)
|
|
return;
|
|
resourceReloadTimer = setTimeout(() => {
|
|
resourceReloadTimer = null;
|
|
if ((reloadAfterResourceApproval || resourceConsentUiActive) && pendingResourceChecks === 0 && mainWindow && !mainWindow.isDestroyed()) {
|
|
reloadAfterResourceApproval = false;
|
|
resourceConsentUiActive = false;
|
|
debugLog("[Wo] Returning to page after resource approval decisions");
|
|
mainWindow.webContents.loadURL(resourceApprovalPageUrl).catch((error) => {
|
|
debugLog("[Wo] Could not restore page after resource approval:", String(error));
|
|
});
|
|
}
|
|
}, 250);
|
|
};
|
|
mainWindow.webContents.on("will-navigate", handleResourceConsentNavigation);
|
|
mainWindow.webContents.on("did-finish-load", () => {
|
|
if (!resourceConsentUiActive)
|
|
mainPageLoadFinished = true;
|
|
});
|
|
mainWindow.webContents.on("did-start-navigation", (_event, navigationUrl, _isInPlace, isMainFrame) => {
|
|
if (!isMainFrame)
|
|
return;
|
|
portalUiRendererReady = false;
|
|
if (resourceConsentUiActive)
|
|
return;
|
|
mainPageLoadFinished = false;
|
|
if (resourceReloadTimer)
|
|
clearTimeout(resourceReloadTimer);
|
|
resourceReloadTimer = null;
|
|
reloadAfterResourceApproval = false;
|
|
pendingResourceChecks = 0;
|
|
navigationGeneration += 1;
|
|
deniedResourceKeys.clear();
|
|
const wasFirstNavigation = firstNavigation;
|
|
firstNavigation = false;
|
|
try {
|
|
const requestedUrl = new URL(pageUrl);
|
|
const navigatingUrl = new URL(navigationUrl);
|
|
requestedUrl.hash = "";
|
|
navigatingUrl.hash = "";
|
|
if (requestedUrl.href === navigatingUrl.href)
|
|
return;
|
|
} catch {
|
|
if (wasFirstNavigation)
|
|
return;
|
|
}
|
|
apiGrants.clear();
|
|
apiGrantPageUrl = null;
|
|
scriptLockActive = true;
|
|
try {
|
|
const currentPage = new URL(navigationUrl);
|
|
currentPage.hash = "";
|
|
resourceApprovalPageUrl = currentPage.href;
|
|
} catch {
|
|
resourceApprovalPageUrl = navigationUrl;
|
|
}
|
|
lockedScriptUrls.clear();
|
|
verifiedScriptCache.clear();
|
|
});
|
|
mainWindow.webContents.session.webRequest.onBeforeRequest((details, callback) => {
|
|
if (details.url.startsWith("wo-cache://")) {
|
|
callback({});
|
|
return;
|
|
}
|
|
if (!scriptLockActive || !mainWindow || details.webContentsId !== mainWindow.webContents.id || !["script", "worker", "stylesheet"].includes(details.resourceType)) {
|
|
callback({});
|
|
return;
|
|
}
|
|
const hash = lockedScriptUrls.get(details.url);
|
|
const isStylesheet = details.resourceType === "stylesheet";
|
|
if (hash) {
|
|
debugLog(`[Wo] Locking verified script ${details.url} -> ${hash.slice(0, 12)}`);
|
|
callback({ redirectURL: woCacheUrl(hash, details.url, isStylesheet) });
|
|
return;
|
|
}
|
|
const approvalPageUrl = resourceApprovalPageUrl;
|
|
const approvalKey = resourceApprovalKey(approvalPageUrl, details.url);
|
|
const requestGeneration = navigationGeneration;
|
|
pendingResourceChecks += 1;
|
|
let checkFinished = false;
|
|
const finishCheck = (approved) => {
|
|
if (checkFinished)
|
|
return;
|
|
checkFinished = true;
|
|
if (requestGeneration !== navigationGeneration)
|
|
return;
|
|
finishResourceCheck(approved);
|
|
};
|
|
loadApprovedResource(approvalPageUrl, details.url).then((cachedHash) => {
|
|
if (requestGeneration !== navigationGeneration) {
|
|
callback({ cancel: true });
|
|
finishCheck(false);
|
|
return;
|
|
}
|
|
if (cachedHash) {
|
|
callback({ redirectURL: woCacheUrl(cachedHash, details.url, isStylesheet) });
|
|
finishCheck(false);
|
|
return;
|
|
}
|
|
callback({ cancel: true });
|
|
if (deniedResourceKeys.has(approvalKey)) {
|
|
debugLog(`[Wo] Resource remains blocked after user denial: ${details.url}`);
|
|
finishCheck(false);
|
|
return;
|
|
}
|
|
approveAndCacheResource(approvalPageUrl, details.url, details.resourceType, siteAssets.styles).then((approvedHash) => {
|
|
if (!approvedHash) {
|
|
deniedResourceKeys.add(approvalKey);
|
|
debugLog(`[Wo] Blocked unapproved resource: ${details.url} (${details.resourceType})`);
|
|
finishCheck(false);
|
|
return;
|
|
}
|
|
finishCheck(true);
|
|
}).catch((error) => {
|
|
debugLog(`[Wo] Could not approve/cache resource ${details.url}: ${String(error)}`);
|
|
finishCheck(false);
|
|
});
|
|
}).catch((error) => {
|
|
callback({ cancel: true });
|
|
debugLog(`[Wo] Could not check approved resource cache for ${details.url}: ${String(error)}`);
|
|
finishCheck(false);
|
|
});
|
|
});
|
|
mainWindow.webContents.session.webRequest.onErrorOccurred((details) => {
|
|
if (mainWindow && details.webContentsId === mainWindow.webContents.id) {
|
|
debugLog(`[Wo] Resource request failed: ${details.error} ${details.resourceType} ${details.url}`);
|
|
}
|
|
});
|
|
mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
|
callback({
|
|
responseHeaders: {
|
|
...details.responseHeaders,
|
|
"Cross-Origin-Opener-Policy": ["same-origin"],
|
|
"Cross-Origin-Embedder-Policy": ["require-corp"],
|
|
...scriptLockActive && ["script", "worker", "stylesheet"].includes(details.resourceType) ? { "Access-Control-Allow-Origin": ["*"], "Cross-Origin-Resource-Policy": ["cross-origin"] } : {}
|
|
}
|
|
});
|
|
});
|
|
debugLog("[Wo] BrowserWindow created, CLIENT_MODE=" + CLIENT_MODE + " offscreen=" + mainWindow.webContents.isOffscreen() + " painting=" + mainWindow.webContents.isPainting());
|
|
mainWindow.webContents.on("console-message", (details) => {
|
|
debugLog(`[Wo] page console level=${details.level} ${details.sourceId}:${details.lineNumber}: ${details.message}`);
|
|
});
|
|
mainWindow.webContents.on("render-process-gone", (_event, details) => {
|
|
debugLog("[Wo] page render process exited:", JSON.stringify(details));
|
|
});
|
|
setupIpcHandlers();
|
|
debugLog("[Wo] setupIpcHandlers completed");
|
|
mainWindow.webContents.send("wo:windows", clientWindows);
|
|
mainWindow.webContents.on("dom-ready", () => {
|
|
if (siteAssets.trusted && !resourceConsentUiActive) {
|
|
if (siteAssets.styles && mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.insertCSS(siteAssets.styles, { cssOrigin: "user" }).catch((error) => {
|
|
debugLog("[Wo] Could not apply _wo.components.css:", String(error));
|
|
});
|
|
}
|
|
}
|
|
});
|
|
let pageHasLoaded = false;
|
|
mainWindow.webContents.on("did-navigate", (_event, navigatedUrl) => {
|
|
if (/^https?:/.test(navigatedUrl))
|
|
pageHasLoaded = true;
|
|
});
|
|
mainWindow.webContents.on("did-fail-load", (_event, errorCode, errorDescription, failedUrl, isMainFrame) => {
|
|
if (!isMainFrame || resourceConsentUiActive || errorCode === -3 || pageHasLoaded || !/^https?:/.test(failedUrl))
|
|
return;
|
|
debugLog(`[Wo] Page load failed (${errorCode} ${errorDescription}); retrying ${failedUrl}`);
|
|
setTimeout(() => {
|
|
if (mainWindow && !mainWindow.isDestroyed() && !resourceConsentUiActive && !pageHasLoaded) {
|
|
mainWindow.loadURL(pageUrl).catch((error) => debugLog("[Wo] Page retry failed:", String(error)));
|
|
}
|
|
}, 750);
|
|
});
|
|
if (CLIENT_MODE) {
|
|
return;
|
|
}
|
|
debugLog("[Wo] Loading content URL: " + pageUrl);
|
|
try {
|
|
const loadPromise = mainWindow.loadURL(pageUrl);
|
|
let loadTimeout;
|
|
const navigationOutcome = await Promise.race([
|
|
loadPromise.then(() => "loaded"),
|
|
new Promise((resolve) => {
|
|
loadTimeout = setTimeout(() => {
|
|
debugLog("[Wo] Page navigation is still pending after 3s; continuing compositor startup");
|
|
resolve("pending");
|
|
}, 3000);
|
|
})
|
|
]).finally(() => {
|
|
if (loadTimeout)
|
|
clearTimeout(loadTimeout);
|
|
});
|
|
if (navigationOutcome === "loaded")
|
|
debugLog("[Wo] Content loaded from: " + pageUrl);
|
|
} catch (e) {
|
|
debugLog("[Wo] Load failed:", String(e));
|
|
}
|
|
try {
|
|
const pageState = await mainWindow.webContents.executeJavaScript(`(() => {
|
|
const root = document.getElementById('root');
|
|
const wallpaper = document.querySelector('.wo-wallpaper');
|
|
const portalCard = document.querySelector('main[role="dialog"]');
|
|
return {
|
|
readyState: document.readyState,
|
|
title: document.title,
|
|
bodyText: (document.body?.innerText || '').slice(0, 240),
|
|
bodyBackground: document.body ? getComputedStyle(document.body).backgroundColor : '',
|
|
bodyBackgroundImage: document.body ? getComputedStyle(document.body).backgroundImage : '',
|
|
bodyDisplay: document.body ? getComputedStyle(document.body).display : '',
|
|
portalCardBackground: portalCard ? getComputedStyle(portalCard).backgroundColor : '',
|
|
portalCardBounds: portalCard ? (() => { const r = portalCard.getBoundingClientRect(); return { x:r.x, y:r.y, width:r.width, height:r.height }; })() : null,
|
|
buttons: document.querySelectorAll('button').length,
|
|
resourceConsentBridge: !!window.woResourceConsent,
|
|
rootChildren: root?.childElementCount ?? -1,
|
|
shellPresent: !!document.querySelector('.wo-shell'),
|
|
wallpaperImage: wallpaper ? getComputedStyle(wallpaper).backgroundImage : '',
|
|
compositorApi: !!window.compositor,
|
|
woApi: !!window.wo,
|
|
};
|
|
})()`);
|
|
debugLog("[Wo] Page render state:", JSON.stringify(pageState));
|
|
} catch (error) {
|
|
debugLog("[Wo] Could not inspect loaded page:", String(error));
|
|
}
|
|
debugLog("[Wo] Page initialized, setting up rendering");
|
|
mainWindow.webContents.focus();
|
|
if (!CLIENT_MODE) {
|
|
configureFramePacing();
|
|
mainWindow.webContents.on("did-finish-load", configureFramePacing);
|
|
let patchBuf = null;
|
|
let emptyPaintRecoveryAt = 0;
|
|
let emptyPaintLogged = false;
|
|
let skippedCount = 0;
|
|
let loggedFirstBitmapFrame = false;
|
|
let loggedSecondBitmapFrame = false;
|
|
mainWindow.webContents.on("paint", (paintEvent, dirty, image) => {
|
|
const sharedTexture = paintEvent.texture;
|
|
if (sharedTexture) {
|
|
if (sharedDmabufStatus !== true) {
|
|
sharedDmabufStatus = true;
|
|
publishPerformanceStatus();
|
|
}
|
|
let releasedByAck = false;
|
|
try {
|
|
if (!dmabufSender || !ipcConnected || appUsedDamageHelper || inFlightFrameSeqs.size >= MAX_IN_FLIGHT_FRAMES) {
|
|
return;
|
|
}
|
|
const seq = dmabufSender.sendSharedTexture(sharedTexture.textureInfo, () => sharedTexture.release());
|
|
releasedByAck = true;
|
|
inFlightFrameSeqs.add(seq.toString());
|
|
} catch (error) {
|
|
debugLog("[Wo] Native shared-texture frame failed:", String(error));
|
|
sharedDmabufStatus = false;
|
|
publishPerformanceStatus();
|
|
} finally {
|
|
if (!releasedByAck)
|
|
sharedTexture.release();
|
|
}
|
|
return;
|
|
}
|
|
if (sharedDmabufStatus !== false) {
|
|
sharedDmabufStatus = false;
|
|
publishPerformanceStatus();
|
|
}
|
|
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 (size.width <= 0 || size.height <= 0) {
|
|
const now = Date.now();
|
|
if (!emptyPaintLogged) {
|
|
const contentSize = mainWindow?.getContentSize() ?? [0, 0];
|
|
debugLog(`[Wo] Ignoring empty offscreen paint ${size.width}x${size.height}; content=${contentSize[0]}x${contentSize[1]} dirty=${dirty.x},${dirty.y},${dirty.width},${dirty.height}`);
|
|
emptyPaintLogged = true;
|
|
}
|
|
if (now - emptyPaintRecoveryAt >= 250) {
|
|
emptyPaintRecoveryAt = now;
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
const [contentWidth, contentHeight] = mainWindow.getContentSize();
|
|
if (contentWidth <= 0 || contentHeight <= 0) {
|
|
mainWindow.setSize(initialWindowWidth, initialWindowHeight);
|
|
}
|
|
mainWindow.webContents.invalidate();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
let resized = !damageBuffer;
|
|
if (!damageBuffer) {
|
|
damageBuffer = new DamageBuffer(size.width, size.height);
|
|
} else if (damageBuffer.getWidth() !== size.width || damageBuffer.getHeight() !== size.height) {
|
|
damageBuffer.reset(size.width, size.height);
|
|
resized = true;
|
|
}
|
|
const pixels = image.toBitmap();
|
|
const pixelData = new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength);
|
|
if (pixelData.byteLength < size.width * size.height * 4) {
|
|
if (!emptyPaintLogged) {
|
|
debugLog(`[Wo] Ignoring incomplete offscreen bitmap: ${pixelData.byteLength} bytes for ${size.width}x${size.height}`);
|
|
emptyPaintLogged = true;
|
|
}
|
|
if (Date.now() - emptyPaintRecoveryAt >= 250 && mainWindow && !mainWindow.isDestroyed()) {
|
|
emptyPaintRecoveryAt = Date.now();
|
|
mainWindow.webContents.invalidate();
|
|
}
|
|
return;
|
|
}
|
|
emptyPaintLogged = false;
|
|
if (!loggedFirstBitmapFrame) {
|
|
const centerOffset = (Math.floor(size.height / 2) * size.width + Math.floor(size.width / 2)) * 4;
|
|
const sampleOffsets = [0, Math.max(0, Math.floor(pixelData.byteLength / 2 / 4) * 4), centerOffset, Math.max(0, pixelData.byteLength - 4)];
|
|
const samples = sampleOffsets.map((offset) => Array.from(pixelData.subarray(offset, offset + 4)));
|
|
debugLog(`[Wo] First bitmap pixels (BGRA top-left/mid-left/center/bottom-right): ${JSON.stringify(samples)}`);
|
|
} else if (!loggedSecondBitmapFrame) {
|
|
const centerOffset = (Math.floor(size.height / 2) * size.width + Math.floor(size.width / 2)) * 4;
|
|
const sampleOffsets = [0, Math.max(0, Math.floor(pixelData.byteLength / 2 / 4) * 4), centerOffset, Math.max(0, pixelData.byteLength - 4)];
|
|
const samples = sampleOffsets.map((offset) => Array.from(pixelData.subarray(offset, offset + 4)));
|
|
debugLog(`[Wo] Second bitmap pixels (BGRA top-left/mid-left/center/bottom-right): ${JSON.stringify(samples)}`);
|
|
loggedSecondBitmapFrame = true;
|
|
}
|
|
const isFullFrame = resized || dirty.x === 0 && dirty.y === 0 && dirty.width === size.width && dirty.height === size.height;
|
|
if (isFullFrame) {
|
|
applyDamagePayload(damageBuffer, {
|
|
width: size.width,
|
|
height: size.height,
|
|
fullFrame: pixelData
|
|
});
|
|
} else {
|
|
const stride = size.width * 4;
|
|
const patchStride = dirty.width * 4;
|
|
const patchBytes = patchStride * dirty.height;
|
|
if (!patchBuf || patchBuf.byteLength < patchBytes) {
|
|
patchBuf = new Uint8Array(patchBytes);
|
|
}
|
|
const patchData = patchBuf.byteLength === patchBytes ? patchBuf : patchBuf.subarray(0, patchBytes);
|
|
for (let row = 0;row < dirty.height; row++) {
|
|
const srcOff = (dirty.y + row) * stride + dirty.x * 4;
|
|
const dstOff = row * patchStride;
|
|
patchData.set(pixelData.subarray(srcOff, srcOff + patchStride), dstOff);
|
|
}
|
|
applyDamagePayload(damageBuffer, {
|
|
width: size.width,
|
|
height: size.height,
|
|
patches: [{
|
|
rect: { x: dirty.x, y: dirty.y, width: dirty.width, height: dirty.height },
|
|
rgba: patchData,
|
|
stride: patchStride
|
|
}]
|
|
});
|
|
}
|
|
try {
|
|
const seq = dmabufSender.send(damageBuffer);
|
|
inFlightFrameSeqs.add(seq.toString());
|
|
if (!loggedFirstBitmapFrame) {
|
|
debugLog(`[Wo] First bitmap frame sent as DMA-BUF: seq=${seq} size=${size.width}x${size.height} stride=${damageBuffer.getStride()}`);
|
|
loggedFirstBitmapFrame = true;
|
|
}
|
|
} 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 (framePacer)
|
|
clearInterval(framePacer);
|
|
framePacer = null;
|
|
framePacerRate = 0;
|
|
});
|
|
}
|
|
app.on("ready", async () => {
|
|
debugLog("[Wo] App ready, CLIENT_MODE=", CLIENT_MODE, "WO_STANDALONE=", process.env.WO_STANDALONE);
|
|
displayScreen = (await import("electron")).screen;
|
|
displayScreen.on("display-added", configureFramePacing);
|
|
displayScreen.on("display-removed", configureFramePacing);
|
|
displayScreen.on("display-metrics-changed", configureFramePacing);
|
|
setupWoCacheProtocol();
|
|
const gpuInfo = await app.getGPUInfo("complete").catch((error) => ({ error: String(error) }));
|
|
const gpuFeatures = app.getGPUFeatureStatus();
|
|
gpuCompositingStatus = gpuFeatures.gpu_compositing || "unknown";
|
|
webglStatus = gpuFeatures.webgl || "unknown";
|
|
const threadedTextureMailbox = gpuInfo.auxAttributes?.canSupportThreadedTextureMailbox;
|
|
offscreenSharedTextureEnabled = gpuCompositingStatus === "enabled" && threadedTextureMailbox !== false;
|
|
debugLog("[Wo] GPU feature status:", JSON.stringify(gpuFeatures));
|
|
debugLog("[Wo] GPU info:", JSON.stringify(gpuInfo));
|
|
debugLog(`[Wo] Offscreen shared texture: ${offscreenSharedTextureEnabled ? "enabled" : "disabled"} (GPU compositor=${gpuCompositingStatus}, threaded texture mailbox=${threadedTextureMailbox ?? "unknown"}, WebGL=${webglStatus})`);
|
|
publishPerformanceStatus();
|
|
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", () => {
|
|
if (audioCapture && !audioCapture.killed)
|
|
audioCapture.kill();
|
|
audioCapture = null;
|
|
stopPortalUiBridge();
|
|
closeShmFdCache();
|
|
});
|
|
export {
|
|
connectToCompositor,
|
|
sendActionToCompositor
|
|
};
|