Initial commit

This commit is contained in:
Hellings 2026-10-26 00:05:21 -04:00
parent 30102c9871
commit 739e24b38b
9 changed files with 705 additions and 158 deletions

View file

@ -66,11 +66,14 @@ script assets. Wo prefetches and verifies those files before navigation, caches
verified assets by URL and hash, then asks the user before exposing the verified assets by URL and hash, then asks the user before exposing the
requested API groups. Granting access applies only to that page/window; a requested API groups. Granting access applies only to that page/window; a
top-level navigation revokes it. Scripts for a manifest-enabled page are served top-level navigation revokes it. Scripts for a manifest-enabled page are served
from Wo's verified cache, and unlisted script loads are blocked. from Wo's verified cache, and unlisted script loads are blocked. A saved grant
is reused only for the same origin, document hash, declared script hashes, and
capability request.
The available permission names are `window-management`, `overlay-rendering`, The available permission names are `window-management`, `overlay-rendering`,
`desktop-applications`, `xdg`, `dbus`, `portals`, `filesystem`, `desktop-applications`, `xdg`, `dbus`, `portals`, `filesystem`,
`process-launch`, `system-control`, `notifications`, and `audio-capture`. A `process-launch`, `system-control`, `notifications`, `audio-capture`, and
`settings`. A
page can inspect `window.wo.permissions.requested`, `.granted`, and `.has(name)`. page can inspect `window.wo.permissions.requested`, `.granted`, and `.has(name)`.
The compositor bridge groups (`window.compositor` and `window.woClient`) are The compositor bridge groups (`window.compositor` and `window.woClient`) are
present only after at least one API group is approved; unapproved groups are present only after at least one API group is approved; unapproved groups are
@ -95,6 +98,18 @@ component theme tokens and keyboard focus styling that Wo preloads and applies
to the page. Websites can use the same manifest format to request a window to the page. Websites can use the same manifest format to request a window
surface or use the overlay-rendering bridge for HTML/CSS/JS surfaces. surface or use the overlay-rendering bridge for HTML/CSS/JS surfaces.
API consent is remembered per origin and exact site build. Wo records the
verified HTML hash, the declared JavaScript asset hashes, and the requested
capability set. The grant survives restarts; changing the HTML, a declared
script, or requested capabilities produces a new consent request. Sites with
`settings` can use `window.wo.settings` (`get`, `set`, `remove`, `keys`) for
persistent JSON values. Wo keeps each web origin in its own user settings file
with owner-only permissions, so apps can share their settings across pages on
the same origin without sharing them with other hosts. Store passwords and
tokens with `getSecret`, `setSecret`, and `removeSecret`; Wo encrypts these using
GNOME Keyring/Secret Service or KDE Wallet and declines secret writes when a
secure desktop keyring is unavailable.
Sites with `window-management` can subscribe to the compositor's monitor Sites with `window-management` can subscribe to the compositor's monitor
layout with `window.wo.windows?.monitors(callback)`. Sites with layout with `window.wo.windows?.monitors(callback)`. Sites with
`overlay-rendering` can pin an element to a monitor's global desktop rectangle: `overlay-rendering` can pin an element to a monitor's global desktop rectangle:

328
electron/dist/main.js vendored
View file

@ -1,5 +1,5 @@
// src/main.ts // src/main.ts
import { app, BrowserWindow, dialog, ipcMain, Menu, protocol } from "electron"; import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, safeStorage } from "electron";
import { createConnection, createServer } from "net"; import { createConnection, createServer } from "net";
import { fileURLToPath as fileURLToPath2 } from "url"; import { fileURLToPath as fileURLToPath2 } from "url";
import { dirname, resolve as resolvePath, join as joinPath } from "path"; import { dirname, resolve as resolvePath, join as joinPath } from "path";
@ -281,7 +281,8 @@ var WO_CAPABILITIES = [
"process-launch", "process-launch",
"system-control", "system-control",
"notifications", "notifications",
"audio-capture" "audio-capture",
"settings"
]; ];
var MAX_INDEX_BYTES = 4 * 1024 * 1024; var MAX_INDEX_BYTES = 4 * 1024 * 1024;
var MAX_ASSET_BYTES = 16 * 1024 * 1024; var MAX_ASSET_BYTES = 16 * 1024 * 1024;
@ -791,7 +792,7 @@ function setupWoCacheProtocol() {
debugLog(`[Wo] Could not rewrite cached module imports for ${hash.slice(0, 12)}: ${String(error)}`); debugLog(`[Wo] Could not rewrite cached module imports for ${hash.slice(0, 12)}: ${String(error)}`);
} }
} }
return new Response(responseBytes, { return new Response(new Uint8Array(responseBytes), {
headers: { headers: {
"Content-Type": isStylesheet ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8", "Content-Type": isStylesheet ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8",
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
@ -968,6 +969,164 @@ function resourceApprovalKey(pageUrl, resourceUrl) {
return createHash2("sha256").update(`${pageUrl} return createHash2("sha256").update(`${pageUrl}
${resourceUrl}`).digest("hex"); ${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() { function resourceCacheDirectory() {
return joinPath(app.getPath("userData"), "wo-approved-resources"); return joinPath(app.getPath("userData"), "wo-approved-resources");
} }
@ -1206,9 +1365,16 @@ function debugLog(...args) {
} }
debugLog("[Wo] Electron starting, NODE_VERSION=", process.version); debugLog("[Wo] Electron starting, NODE_VERSION=", process.version);
var requestedOzonePlatform = (process.env.WO_ELECTRON_OZONE_PLATFORM || "auto").toLowerCase(); var requestedOzonePlatform = (process.env.WO_ELECTRON_OZONE_PLATFORM || "auto").toLowerCase();
var defaultGpuBackend = "angle"; var defaultGpuBackend = "auto";
var requestedGpuBackend = (process.env.WO_ELECTRON_GPU_BACKEND || defaultGpuBackend).toLowerCase(); var requestedGpuBackend = (process.env.WO_ELECTRON_GPU_BACKEND || defaultGpuBackend).toLowerCase();
var gpuBackend = ["vulkan", "gl", "angle", "desktop", "auto"].includes(requestedGpuBackend) ? requestedGpuBackend : defaultGpuBackend; 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") { if (gpuBackend === "desktop") {
app.commandLine.appendSwitch("use-gl", "desktop"); app.commandLine.appendSwitch("use-gl", "desktop");
} else if (gpuBackend === "angle") { } else if (gpuBackend === "angle") {
@ -1229,9 +1395,9 @@ var chromiumFeatures = [
]; ];
if (gpuBackend === "vulkan") { if (gpuBackend === "vulkan") {
chromiumFeatures.push("Vulkan", "DefaultANGLEVulkan", "VulkanFromANGLE"); chromiumFeatures.push("Vulkan", "DefaultANGLEVulkan", "VulkanFromANGLE");
} else if (requestedOzonePlatform === "wayland" || gpuBackend !== "auto") { } else if (waylandOzone || gpuBackend !== "auto") {
app.commandLine.appendSwitch("disable-vulkan"); app.commandLine.appendSwitch("disable-vulkan");
app.commandLine.appendSwitch("disable-features", "Vulkan"); app.commandLine.appendSwitch("disable-features", "Vulkan,DefaultANGLEVulkan,VulkanFromANGLE");
} }
app.commandLine.appendSwitch("enable-features", chromiumFeatures.join(",")); app.commandLine.appendSwitch("enable-features", chromiumFeatures.join(","));
app.commandLine.appendSwitch("enable-accelerated-video-decode"); app.commandLine.appendSwitch("enable-accelerated-video-decode");
@ -2587,6 +2753,63 @@ function sendSiteHashesToCompositor() {
} }
function setupIpcHandlers() { function setupIpcHandlers() {
ipcMain.handle("wo:performance-status", () => getPerformanceStatus()); 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) => { ipcMain.on("wo:portal-ui-ready", (event) => {
if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents) if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents)
return; return;
@ -2796,8 +3019,7 @@ function setupIpcHandlers() {
theme: typeof params.theme === "string" ? params.theme : undefined theme: typeof params.theme === "string" ? params.theme : undefined
}) ?? null; }) ?? null;
} }
case "launch": case "launch": {
case "exec": {
const { spawn } = await import("child_process"); const { spawn } = await import("child_process");
const command = params.command || ""; const command = params.command || "";
if (!command) { if (!command) {
@ -2815,6 +3037,26 @@ function setupIpcHandlers() {
return { ok: false, error: String(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": { case "listdir": {
const fs2 = await import("fs"); const fs2 = await import("fs");
const path = await import("path"); const path = await import("path");
@ -3069,24 +3311,39 @@ async function createWindow() {
apiGrantPageUrl = siteAssets.pageUrl; apiGrantPageUrl = siteAssets.pageUrl;
debugLog("[Wo] Granted verified comraw system-shell capabilities"); debugLog("[Wo] Granted verified comraw system-shell capabilities");
} else if (siteAssets.trusted && siteAssets.capabilities.length > 0) { } else if (siteAssets.trusted && siteAssets.capabilities.length > 0) {
const permissionResult = await dialog.showMessageBox({ const cachedGrant = await loadApiConsent(siteAssets);
type: "question", if (cachedGrant) {
title: "Wo API permission request", apiGrants = new Set(cachedGrant);
message: `${siteAssets.appId} requests access to Wo APIs`, apiGrantPageUrl = siteAssets.pageUrl;
detail: `Page: ${pageUrl} debugLog(`[Wo] Restored remembered API consent for ${siteAssets.appId} (${apiConsentKey(siteAssets).slice(0, 12)})`);
Verified index.html SHA-256: ${siteAssets.indexSha256} } 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(", ")} Requested permissions: ${siteAssets.capabilities.join(", ")}
The site APIs are exposed only for this window while it is open.`, Allowing remembers this decision for this origin and these exact HTML/JavaScript assets. Wo will ask again if any of them change.`,
buttons: ["Allow requested APIs", "Open without Wo APIs"], buttons: ["Allow and remember", "Open without Wo APIs"],
defaultId: 1, defaultId: 1,
cancelId: 1, cancelId: 1,
noLink: true noLink: true
}); });
if (permissionResult.response === 0) { if (permissionResult.response === 0) {
apiGrants = new Set(siteAssets.capabilities); apiGrants = new Set(siteAssets.capabilities);
apiGrantPageUrl = siteAssets.pageUrl; 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 requested = siteAssets.trusted ? siteAssets.capabilities : [];
@ -3312,14 +3569,21 @@ The site APIs are exposed only for this window while it is open.`,
debugLog("[Wo] Loading content URL: " + pageUrl); debugLog("[Wo] Loading content URL: " + pageUrl);
try { try {
const loadPromise = mainWindow.loadURL(pageUrl); const loadPromise = mainWindow.loadURL(pageUrl);
await Promise.race([ let loadTimeout;
loadPromise, const navigationOutcome = await Promise.race([
new Promise((r) => setTimeout(() => { loadPromise.then(() => "loaded"),
debugLog("[Wo] Load timeout, continuing"); new Promise((resolve) => {
r(undefined); loadTimeout = setTimeout(() => {
}, 3000)) debugLog("[Wo] Page navigation is still pending after 3s; continuing compositor startup");
]); resolve("pending");
debugLog("[Wo] Content loaded from: " + pageUrl); }, 3000);
})
]).finally(() => {
if (loadTimeout)
clearTimeout(loadTimeout);
});
if (navigationOutcome === "loaded")
debugLog("[Wo] Content loaded from: " + pageUrl);
} catch (e) { } catch (e) {
debugLog("[Wo] Load failed:", String(e)); debugLog("[Wo] Load failed:", String(e));
} }

View file

@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, protocol } from 'electron'; import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, safeStorage } from 'electron';
import { createConnection, createServer, Socket, Server } from 'net'; import { createConnection, createServer, Socket, Server } from 'net';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, resolve as resolvePath, join as joinPath } from 'path'; import { dirname, resolve as resolvePath, join as joinPath } from 'path';
@ -57,7 +57,7 @@ function setupWoCacheProtocol() {
debugLog(`[Wo] Could not rewrite cached module imports for ${hash.slice(0, 12)}: ${String(error)}`); debugLog(`[Wo] Could not rewrite cached module imports for ${hash.slice(0, 12)}: ${String(error)}`);
} }
} }
return new Response(responseBytes, { return new Response(new Uint8Array(responseBytes), {
headers: { headers: {
'Content-Type': isStylesheet ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8', 'Content-Type': isStylesheet ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8',
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@ -218,6 +218,168 @@ function resourceApprovalKey(pageUrl: string, resourceUrl: string): string {
return createHash('sha256').update(`${pageUrl}\n${resourceUrl}`).digest('hex'); return createHash('sha256').update(`${pageUrl}\n${resourceUrl}`).digest('hex');
} }
function apiConsentKey(site: WoSiteAssets): string {
let origin = '';
try { origin = new URL(site.pageUrl).origin; } catch { /* untrusted URLs are not cached */ }
const identity = {
origin,
appId: site.appId,
indexSha256: site.indexSha256,
scriptSha256: site.scriptBodies.map((script) => script.hash).sort(),
capabilities: [...site.capabilities].sort(),
};
return createHash('sha256').update(JSON.stringify(identity)).digest('hex');
}
function apiConsentPath(key: string): string {
return joinPath(app.getPath('userData'), 'wo-api-consents', `${key}.json`);
}
async function loadApiConsent(site: WoSiteAssets): Promise<WoCapability[] | null> {
if (!site.trusted || !site.indexSha256) return null;
try {
const key = apiConsentKey(site);
const record = JSON.parse(await fs.promises.readFile(apiConsentPath(key), 'utf8')) as {
key?: string; origin?: string; indexSha256?: string; scriptSha256?: string[];
capabilities?: WoCapability[]; granted?: WoCapability[];
};
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: WoSiteAssets, granted: WoCapability[]): Promise<void> {
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: 0o700 });
await fs.promises.writeFile(temporary, JSON.stringify(record, null, 2), { mode: 0o600 });
await fs.promises.rename(temporary, path);
}
const MAX_SITE_SETTINGS_BYTES = 64 * 1024;
const SETTINGS_KEY_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
const siteSettingsTransactions = new Map<string, Promise<void>>();
const siteSecretTransactions = new Map<string, Promise<void>>();
function siteSettingsPath(origin: string): string {
const originHash = createHash('sha256').update(origin).digest('hex');
return joinPath(app.getPath('userData'), 'site-settings', `${originHash}.json`);
}
function siteSecretsPath(origin: string): string {
const originHash = createHash('sha256').update(origin).digest('hex');
return joinPath(app.getPath('userData'), 'site-secrets', `${originHash}.json`);
}
function secureSecretStorageAvailable(): boolean {
if (!safeStorage.isEncryptionAvailable()) return false;
const backend = safeStorage.getSelectedStorageBackend();
return ['gnome_libsecret', 'kwallet', 'kwallet5', 'kwallet6'].includes(backend);
}
async function readSiteSecrets(origin: string): Promise<Record<string, string>> {
if (!secureSecretStorageAvailable()) throw new Error('Secure secret storage requires GNOME Keyring/Secret Service or KDE Wallet');
let stored: Record<string, string>;
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 as NodeJS.ErrnoException).code === 'ENOENT') return Object.create(null);
throw error;
}
const decrypted = Object.create(null) as Record<string, string>;
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: string, update: (secrets: Record<string, string>) => void): Promise<void> {
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(() => undefined).then(async () => {
const secrets = await readSiteSecrets(origin);
update(secrets);
const encrypted = Object.create(null) as Record<string, string>;
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: 0o700 });
await fs.promises.writeFile(temporary, JSON.stringify(encrypted), { mode: 0o600 });
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: string): Promise<Record<string, unknown>> {
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) as Record<string, unknown>, value);
} catch {
return {};
}
}
async function writeSiteSettings(origin: string, settings: Record<string, unknown>): Promise<void> {
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: 0o700 });
await fs.promises.writeFile(temporary, bytes, { mode: 0o600 });
await fs.promises.rename(temporary, path);
}
async function updateSiteSettings(
origin: string,
update: (settings: Record<string, unknown>) => void,
): Promise<void> {
const previous = siteSettingsTransactions.get(origin) ?? Promise.resolve();
const transaction = previous.catch(() => undefined).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(): string { function resourceCacheDirectory(): string {
return joinPath(app.getPath('userData'), 'wo-approved-resources'); return joinPath(app.getPath('userData'), 'wo-approved-resources');
} }
@ -469,14 +631,25 @@ debugLog('[Wo] Electron starting, NODE_VERSION=', process.version);
// Wayland without depending on a separate X server; explicit backend choices // Wayland without depending on a separate X server; explicit backend choices
// remain available for systems that need desktop GL or Vulkan. // remain available for systems that need desktop GL or Vulkan.
const requestedOzonePlatform = (process.env.WO_ELECTRON_OZONE_PLATFORM || 'auto').toLowerCase(); const requestedOzonePlatform = (process.env.WO_ELECTRON_OZONE_PLATFORM || 'auto').toLowerCase();
// The nested Wayland surface cannot present through Chromium's Vulkan path. // Let Chromium select the GL implementation from the active Wayland/driver
// Pin ANGLE/OpenGL by default; set WO_ELECTRON_GPU_BACKEND=auto to ask Chromium // stack by default. Chromium's automatic backend currently selects Vulkan on
// to choose its own implementation when diagnosing a driver-specific issue. // some Wayland/NVIDIA setups even when Vulkan is disabled below, so pin native
const defaultGpuBackend = 'angle'; // Wayland to ANGLE's OpenGL renderer. This keeps the hardware EGL/GLES path and
// avoids Chromium's incompatible Wayland/Vulkan surface factory.
const defaultGpuBackend = 'auto';
const requestedGpuBackend = (process.env.WO_ELECTRON_GPU_BACKEND || defaultGpuBackend).toLowerCase(); const requestedGpuBackend = (process.env.WO_ELECTRON_GPU_BACKEND || defaultGpuBackend).toLowerCase();
const gpuBackend = ['vulkan', 'gl', 'angle', 'desktop', 'auto'].includes(requestedGpuBackend) const validatedGpuBackend = ['vulkan', 'gl', 'angle', 'desktop', 'auto'].includes(requestedGpuBackend)
? requestedGpuBackend ? requestedGpuBackend
: defaultGpuBackend; : defaultGpuBackend;
const waylandOzone = requestedOzonePlatform === 'wayland';
const 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') { if (gpuBackend === 'desktop') {
// On X11, ask Chromium for the system GLX/EGL implementation directly. // On X11, ask Chromium for the system GLX/EGL implementation directly.
// ANGLE's GL backend can fail to initialize on nested XWayland and then // ANGLE's GL backend can fail to initialize on nested XWayland and then
@ -500,11 +673,12 @@ const chromiumFeatures = [
]; ];
if (gpuBackend === 'vulkan') { if (gpuBackend === 'vulkan') {
chromiumFeatures.push('Vulkan', 'DefaultANGLEVulkan', 'VulkanFromANGLE'); chromiumFeatures.push('Vulkan', 'DefaultANGLEVulkan', 'VulkanFromANGLE');
} else if (requestedOzonePlatform === 'wayland' || gpuBackend !== 'auto') { } else if (waylandOzone || gpuBackend !== 'auto') {
// Chromium's Linux Wayland surface factory rejects Vulkan. Keep it disabled // Chromium may start its Vulkan surface path even when its reported feature
// while using the explicitly selected ANGLE/OpenGL implementation. // status says Vulkan is disabled. Disable the Vulkan feature family on
// Wayland, while leaving GL/EGL backend selection automatic and accelerated.
app.commandLine.appendSwitch('disable-vulkan'); app.commandLine.appendSwitch('disable-vulkan');
app.commandLine.appendSwitch('disable-features', 'Vulkan'); app.commandLine.appendSwitch('disable-features', 'Vulkan,DefaultANGLEVulkan,VulkanFromANGLE');
} }
app.commandLine.appendSwitch('enable-features', chromiumFeatures.join(',')); app.commandLine.appendSwitch('enable-features', chromiumFeatures.join(','));
app.commandLine.appendSwitch('enable-accelerated-video-decode'); app.commandLine.appendSwitch('enable-accelerated-video-decode');
@ -1845,6 +2019,51 @@ function sendSiteHashesToCompositor(): void {
function setupIpcHandlers() { function setupIpcHandlers() {
ipcMain.handle('wo:performance-status', () => getPerformanceStatus()); ipcMain.handle('wo:performance-status', () => getPerformanceStatus());
ipcMain.handle('wo:settings', async (event, operation: unknown, key: unknown, value?: unknown) => {
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: string;
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 as string];
}
if (operation === 'keys') return Object.keys(await readSiteSettings(origin)).sort();
if (operation === 'getSecret') return (await readSiteSecrets(origin))[key as string];
if (operation === 'removeSecret') {
await updateSiteSecrets(origin, (secrets) => { delete secrets[key as string]; });
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 as string] = value; });
return { ok: true };
}
if (operation === 'remove') {
await updateSiteSettings(origin, (settings) => { delete settings[key as string]; });
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 as string] = JSON.parse(encoded); });
return { ok: true };
} catch (error) {
return { ok: false, error: String(error) };
}
});
ipcMain.on('wo:portal-ui-ready', (event) => { ipcMain.on('wo:portal-ui-ready', (event) => {
if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents) return; if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents) return;
portalUiRendererReady = true; portalUiRendererReady = true;
@ -2067,7 +2286,7 @@ function setupIpcHandlers() {
} }
case 'launch': case 'launch':
case 'exec': { {
const { spawn } = await import('child_process'); const { spawn } = await import('child_process');
const command = (params.command as string) || ''; const command = (params.command as string) || '';
@ -2088,6 +2307,29 @@ function setupIpcHandlers() {
} }
} }
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): arg is string => 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: 15_000, maxBuffer: 1024 * 1024 }, (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': { case 'listdir': {
const fs = await import('fs'); const fs = await import('fs');
const path = await import('path'); const path = await import('path');
@ -2369,19 +2611,33 @@ async function createWindow() {
apiGrantPageUrl = siteAssets.pageUrl; apiGrantPageUrl = siteAssets.pageUrl;
debugLog('[Wo] Granted verified comraw system-shell capabilities'); debugLog('[Wo] Granted verified comraw system-shell capabilities');
} else if (siteAssets.trusted && siteAssets.capabilities.length > 0) { } else if (siteAssets.trusted && siteAssets.capabilities.length > 0) {
const permissionResult = await dialog.showMessageBox({ const cachedGrant = await loadApiConsent(siteAssets);
type: 'question', if (cachedGrant) {
title: 'Wo API permission request', apiGrants = new Set(cachedGrant);
message: `${siteAssets.appId} requests access to Wo APIs`,
detail: `Page: ${pageUrl}\nVerified index.html SHA-256: ${siteAssets.indexSha256}\n\nRequested permissions: ${siteAssets.capabilities.join(', ')}\n\nThe site APIs are exposed only for this window while it is open.`,
buttons: ['Allow requested APIs', 'Open without Wo APIs'],
defaultId: 1,
cancelId: 1,
noLink: true,
});
if (permissionResult.response === 0) {
apiGrants = new Set(siteAssets.capabilities);
apiGrantPageUrl = siteAssets.pageUrl; 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}\nVerified HTML SHA-256: ${siteAssets.indexSha256}\nVerified JavaScript SHA-256: ${scriptHashes}\n\nRequested permissions: ${siteAssets.capabilities.join(', ')}\n\nAllowing 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 requested = siteAssets.trusted ? siteAssets.capabilities : [];
@ -2624,11 +2880,19 @@ async function createWindow() {
debugLog('[Wo] Loading content URL: ' + pageUrl); debugLog('[Wo] Loading content URL: ' + pageUrl);
try { try {
const loadPromise = mainWindow!.loadURL(pageUrl); const loadPromise = mainWindow!.loadURL(pageUrl);
await Promise.race([ let loadTimeout: ReturnType<typeof setTimeout> | undefined;
loadPromise, const navigationOutcome = await Promise.race([
new Promise(r => setTimeout(() => { debugLog('[Wo] Load timeout, continuing'); r(undefined); }, 3000)) loadPromise.then(() => 'loaded' as const),
]); new Promise<'pending'>((resolve) => {
debugLog('[Wo] Content loaded from: ' + pageUrl); 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) { } catch (e) {
debugLog('[Wo] Load failed:', String(e)); debugLog('[Wo] Load failed:', String(e));
} }

View file

@ -31,6 +31,7 @@ import type {
WoPermissionState, WoPermissionState,
WoPerformanceStatus, WoPerformanceStatus,
WoMonitor, WoMonitor,
WoSettings,
} from 'wo-types'; } from 'wo-types';
import { createRendererDamageHelper } from './renderer-damage-helper.js'; import { createRendererDamageHelper } from './renderer-damage-helper.js';
@ -89,6 +90,16 @@ const compositorAPI: CompositorAPI = {
return await ipcRenderer.invoke('wo:syscall', type, params); return await ipcRenderer.invoke('wo:syscall', type, params);
}, },
settings: {
get: <T = unknown>(key: string) => ipcRenderer.invoke('wo:settings', 'get', key) as Promise<T | undefined>,
set: (key: string, value: unknown) => ipcRenderer.invoke('wo:settings', 'set', key, value) as Promise<{ ok: boolean; error?: string }>,
remove: (key: string) => ipcRenderer.invoke('wo:settings', 'remove', key) as Promise<{ ok: boolean; error?: string }>,
keys: () => ipcRenderer.invoke('wo:settings', 'keys') as Promise<string[]>,
getSecret: (key: string) => ipcRenderer.invoke('wo:settings', 'getSecret', key) as Promise<string | undefined>,
setSecret: (key: string, value: string) => ipcRenderer.invoke('wo:settings', 'setSecret', key, value) as Promise<{ ok: boolean; error?: string }>,
removeSecret: (key: string) => ipcRenderer.invoke('wo:settings', 'removeSecret', key) as Promise<{ ok: boolean; error?: string }>,
} satisfies WoSettings,
dbus: { dbus: {
call: (options: DBusCallOptions) => ipcRenderer.invoke('wo:syscall', 'dbus_call', options), call: (options: DBusCallOptions) => ipcRenderer.invoke('wo:syscall', 'dbus_call', options),
getProperty: (options: DBusPropertyOptions) => ipcRenderer.invoke('wo:syscall', 'dbus_get_property', options), getProperty: (options: DBusPropertyOptions) => ipcRenderer.invoke('wo:syscall', 'dbus_get_property', options),
@ -279,6 +290,7 @@ const woClientAPI: WoClientAPI = {
icons: compositorAPI.icons, icons: compositorAPI.icons,
xdg: compositorAPI.xdg, xdg: compositorAPI.xdg,
audio: compositorAPI.audio, audio: compositorAPI.audio,
settings: compositorAPI.settings,
}; };
function readCapabilityArgument(name: string): WoCapability[] { function readCapabilityArgument(name: string): WoCapability[] {
@ -326,6 +338,7 @@ if (!grantedCapabilities.has('dbus')) hide('dbus');
if (!grantedCapabilities.has('xdg')) hide('icons', 'xdg', 'onEnvUpdate'); if (!grantedCapabilities.has('xdg')) hide('icons', 'xdg', 'onEnvUpdate');
if (!grantedCapabilities.has('portals')) hide('portals', 'onPortalRequest', 'onScreencopyEvent'); if (!grantedCapabilities.has('portals')) hide('portals', 'onPortalRequest', 'onScreencopyEvent');
if (!grantedCapabilities.has('audio-capture')) hide('audio'); if (!grantedCapabilities.has('audio-capture')) hide('audio');
if (!grantedCapabilities.has('settings')) hide('settings');
if (!grantedCapabilities.has('notifications')) hide('onNotification'); if (!grantedCapabilities.has('notifications')) hide('onNotification');
if (grantedCapabilities.size > 0) { if (grantedCapabilities.size > 0) {
contextBridge.exposeInMainWorld('compositor', compositorExposure); contextBridge.exposeInMainWorld('compositor', compositorExposure);
@ -341,6 +354,7 @@ if (grantedCapabilities.size > 0) {
if (!grantedCapabilities.has('xdg')) delete clientExposure.icons; if (!grantedCapabilities.has('xdg')) delete clientExposure.icons;
if (!grantedCapabilities.has('xdg')) delete clientExposure.xdg; if (!grantedCapabilities.has('xdg')) delete clientExposure.xdg;
if (!grantedCapabilities.has('audio-capture')) delete clientExposure.audio; if (!grantedCapabilities.has('audio-capture')) delete clientExposure.audio;
if (!grantedCapabilities.has('settings')) delete clientExposure.settings;
contextBridge.exposeInMainWorld('woClient', clientExposure); contextBridge.exposeInMainWorld('woClient', clientExposure);
const woExposure: Record<string, unknown> = { const woExposure: Record<string, unknown> = {
@ -410,6 +424,7 @@ if (grantedCapabilities.size > 0) {
if (grantedCapabilities.has('dbus')) woExposure.dbus = compositorAPI.dbus; if (grantedCapabilities.has('dbus')) woExposure.dbus = compositorAPI.dbus;
if (grantedCapabilities.has('portals')) woExposure.portals = compositorAPI.portals; if (grantedCapabilities.has('portals')) woExposure.portals = compositorAPI.portals;
if (grantedCapabilities.has('audio-capture')) woExposure.audio = compositorAPI.audio; if (grantedCapabilities.has('audio-capture')) woExposure.audio = compositorAPI.audio;
if (grantedCapabilities.has('settings')) woExposure.settings = compositorAPI.settings;
if (grantedCapabilities.has('notifications')) woExposure.onNotification = compositorAPI.onNotification; if (grantedCapabilities.has('notifications')) woExposure.onNotification = compositorAPI.onNotification;
woExposure.syscall = compositorAPI.syscall; woExposure.syscall = compositorAPI.syscall;
contextBridge.exposeInMainWorld('wo', woExposure); contextBridge.exposeInMainWorld('wo', woExposure);

View file

@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
export const WO_CAPABILITIES = [ export const WO_CAPABILITIES = [
'window-management', 'overlay-rendering', 'desktop-applications', 'xdg', 'dbus', 'portals', 'window-management', 'overlay-rendering', 'desktop-applications', 'xdg', 'dbus', 'portals',
'filesystem', 'process-launch', 'system-control', 'notifications', 'audio-capture', 'filesystem', 'process-launch', 'system-control', 'notifications', 'audio-capture', 'settings',
] as const; ] as const;
export type WoCapability = typeof WO_CAPABILITIES[number]; export type WoCapability = typeof WO_CAPABILITIES[number];

View file

@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"target": "esnext", "target": "esnext",
"module": "ESNext", "module": "ESNext",
"lib": ["ES2020"], "lib": ["ES2020", "DOM"],
"moduleResolution": "node", "moduleResolution": "node",
"baseUrl": ".", "baseUrl": ".",

View file

@ -14,6 +14,7 @@ export type {
WoAudio, WoAudio,
WoAudioSource, WoAudioSource,
WoAudioChunk, WoAudioChunk,
WoSettings,
XdgDirectories, XdgDirectories,
DBusBus, DBusBus,
DBusCallOptions, DBusCallOptions,

View file

@ -124,7 +124,7 @@ export interface PortalRequestEvent {
export type WoCapability = export type WoCapability =
| 'window-management' | 'overlay-rendering' | 'desktop-applications' | 'xdg' | 'dbus' | 'window-management' | 'overlay-rendering' | 'desktop-applications' | 'xdg' | 'dbus'
| 'portals' | 'filesystem' | 'process-launch' | 'system-control' | 'notifications' | 'audio-capture'; | 'portals' | 'filesystem' | 'process-launch' | 'system-control' | 'notifications' | 'audio-capture' | 'settings';
export interface WoPermissionState { export interface WoPermissionState {
requested: readonly WoCapability[]; requested: readonly WoCapability[];
@ -161,10 +161,22 @@ export interface WoGlobalAPI {
dbus?: WoDBus; dbus?: WoDBus;
portals?: WoPortals; portals?: WoPortals;
audio?: WoAudio; audio?: WoAudio;
/** Persistent JSON settings isolated to the requesting web origin. */
settings?: WoSettings;
syscall?: Compositor['syscall']; syscall?: Compositor['syscall'];
onNotification?: Compositor['onNotification']; onNotification?: Compositor['onNotification'];
} }
export interface WoSettings {
get<T = unknown>(key: string): Promise<T | undefined>;
set(key: string, value: unknown): Promise<{ ok: boolean; error?: string }>;
remove(key: string): Promise<{ ok: boolean; error?: string }>;
keys(): Promise<string[]>;
getSecret(key: string): Promise<string | undefined>;
setSecret(key: string, value: string): Promise<{ ok: boolean; error?: string }>;
removeSecret(key: string): Promise<{ ok: boolean; error?: string }>;
}
export interface ScreencopyEvent { export interface ScreencopyEvent {
active: boolean; active: boolean;
clientCount: number; clientCount: number;
@ -339,6 +351,7 @@ export interface WoClient {
icons: WoIcons; icons: WoIcons;
xdg: WoXdg; xdg: WoXdg;
audio: WoAudio; audio: WoAudio;
settings: WoSettings;
} }
export interface WoPortals { export interface WoPortals {
@ -419,6 +432,7 @@ export interface Compositor {
icons: WoIcons; icons: WoIcons;
xdg: WoXdg; xdg: WoXdg;
audio: WoAudio; audio: WoAudio;
settings: WoSettings;
/** /**
* Subscribe to focus changes * Subscribe to focus changes

View file

@ -1,34 +1,34 @@
2026-10-26T03:36:18.813061Z  INFO wo: wo compositor starting 2026-10-26T04:03:16.173817Z  INFO wo: wo compositor starting
2026-10-26T03:36:18.813121Z  INFO wo::config: Loading config from "/home/hellings/.config/wo/config.toml" 2026-10-26T04:03:16.173881Z  INFO wo::config: Loading config from "/home/hellings/.config/wo/config.toml"
2026-10-26T03:36:18.813191Z  INFO wo: Attempting nested mode (gles renderer) 2026-10-26T04:03:16.173950Z  INFO wo: Attempting nested mode (gles renderer)
2026-10-26T03:36:18.813218Z  INFO wo::nested: Starting nested compositor mode 2026-10-26T04:03:16.173979Z  INFO wo::nested: Starting nested compositor mode
2026-10-26T03:36:18.813231Z  INFO wo::nested: Press ESC to exit 2026-10-26T04:03:16.173994Z  INFO wo::nested: Press ESC to exit
2026-10-26T03:36:18.813449Z  INFO wo::nested: Wayland socket created socket=wo-0 2026-10-26T04:03:16.174203Z  INFO wo::nested: Wayland socket created socket=wo-0
2026-10-26T03:36:18.813504Z  INFO wo::nested: Using host Wayland display for nested startup socket=wayland-0 2026-10-26T04:03:16.174268Z  INFO wo::nested: Using host Wayland display for nested startup socket=wayland-0
2026-10-26T03:36:18.856187Z ERROR smithay::backend::egl::ffi: [EGL] 0x3004 (BAD_ATTRIBUTE) eglCreateSyncKHR: EGL_BAD_ATTRIBUTE error: In eglCreateSyncKHR: EGL_SYNC_NATIVE_FENCE_FD_ANDROID specified valid fd butEGL_SYNC_STATUS is also being set 2026-10-26T04:03:16.223721Z ERROR smithay::backend::egl::ffi: [EGL] 0x3004 (BAD_ATTRIBUTE) eglCreateSyncKHR: EGL_BAD_ATTRIBUTE error: In eglCreateSyncKHR: EGL_SYNC_NATIVE_FENCE_FD_ANDROID specified valid fd butEGL_SYNC_STATUS is also being set
2026-10-26T03:36:18.911103Z  INFO wo::nested: Nested window size width=1280 height=800 2026-10-26T04:03:16.283939Z  INFO wo::nested: Nested window size width=1280 height=800
2026-10-26T03:36:18.911169Z  INFO wo::nested: Using render node: /dev/dri/renderD128 2026-10-26T04:03:16.283997Z  INFO wo::nested: Using render node: /dev/dri/renderD128
2026-10-26T03:36:18.911238Z  INFO wo::nested: Collected 672 dmabuf formats from renderer 2026-10-26T04:03:16.284065Z  INFO wo::nested: Collected 672 dmabuf formats from renderer
2026-10-26T03:36:18.911502Z  INFO wo: Started configured service before root windows name=vite pid=1378486 2026-10-26T04:03:16.284257Z  INFO wo: Started configured service before root windows name=vite pid=1405610
2026-10-26T03:36:18.913053Z  INFO wo::handlers::screencopy: Registered zwlr_screencopy_manager_v1 global (v3) 2026-10-26T04:03:16.285775Z  INFO wo::handlers::screencopy: Registered zwlr_screencopy_manager_v1 global (v3)
2026-10-26T03:36:18.913289Z  INFO wo::cursor: Loading cursor theme 'Adwaita' (size 24) 2026-10-26T04:03:16.286007Z  INFO wo::cursor: Loading cursor theme 'Adwaita' (size 24)
2026-10-26T03:36:18.913344Z  INFO wo::cursor: Loading cursor theme 'default' (size 24) 2026-10-26T04:03:16.286058Z  INFO wo::cursor: Loading cursor theme 'default' (size 24)
2026-10-26T03:36:18.913378Z  INFO wo::cursor: Loading cursor theme 'DMZ-White' (size 24) 2026-10-26T04:03:16.286091Z  INFO wo::cursor: Loading cursor theme 'DMZ-White' (size 24)
2026-10-26T03:36:18.913400Z  INFO wo::cursor: Loading cursor theme 'breeze' (size 24) 2026-10-26T04:03:16.286114Z  INFO wo::cursor: Loading cursor theme 'breeze' (size 24)
2026-10-26T03:36:18.913478Z  INFO wo::electron: IPC socket listening socket="/run/user/1000/wo-ipc.sock" 2026-10-26T04:03:16.286187Z  INFO wo::electron: IPC socket listening socket="/run/user/1000/wo-ipc.sock"
2026-10-26T03:36:18.916655Z  INFO wo::nested: XWayland server starting 2026-10-26T04:03:16.288689Z  INFO wo::nested: XWayland server starting
2026-10-26T03:36:18.916688Z  INFO wo::nested: Set WAYLAND_DISPLAY=wo-0 2026-10-26T04:03:16.288724Z  INFO wo::nested: Set WAYLAND_DISPLAY=wo-0
2026-10-26T03:36:18.919374Z  INFO wo::state: Propagated ["WAYLAND_DISPLAY"] to DBus activation environment 2026-10-26T04:03:16.291220Z  INFO wo::state: Propagated ["WAYLAND_DISPLAY"] to DBus activation environment
2026-10-26T03:36:18.922988Z  INFO wo::state: Propagated ["WAYLAND_DISPLAY"] to systemd user environment 2026-10-26T04:03:16.294950Z  INFO wo::state: Propagated ["WAYLAND_DISPLAY"] to systemd user environment
2026-10-26T03:36:18.923014Z  INFO wo::nested: Nested compositor running 2026-10-26T04:03:16.294980Z  INFO wo::nested: Nested compositor running
2026-10-26T03:36:18.923039Z  INFO wo::nested: Window resized to 1280x800 2026-10-26T04:03:16.295013Z  INFO wo::nested: Window resized to 1280x800
2026-10-26T03:36:18.923058Z  INFO wo::nested: Window resized to 1280x800 2026-10-26T04:03:16.295036Z  INFO wo::nested: Window resized to 1280x800
2026-10-26T03:36:18.962626Z  INFO wo::handlers::compositor: Client missing ClientData (likely XWayland), using fallback compositor state 2026-10-26T04:03:16.328406Z  INFO wo::handlers::compositor: Client missing ClientData (likely XWayland), using fallback compositor state
2026-10-26T03:36:19.023297Z  INFO wo::nested: Spawned portal service: "/home/hellings/.local/share/wo/bin/wo-portal" 2026-10-26T04:03:16.395419Z  INFO wo::nested: Spawned portal service: "/home/hellings/.local/share/wo/bin/wo-portal"
2026-10-26T03:36:19.025666Z  INFO wo_portal: xdg-desktop-portal-wo starting 2026-10-26T04:03:16.397631Z  INFO wo_portal: xdg-desktop-portal-wo starting
2026-10-26T03:36:19.026881Z  INFO wo_portal: Portal service registered as org.freedesktop.impl.portal.desktop.wo 2026-10-26T04:03:16.398478Z  INFO wo_portal: Portal service registered as org.freedesktop.impl.portal.desktop.wo
2026-10-26T03:36:19.026938Z  INFO wo_portal: Listening for screen capture requests... 2026-10-26T04:03:16.398508Z  INFO wo_portal: Listening for screen capture requests...
(II) GLX: Initialized glamor GL provider for screen 0 (II) GLX: Initialized glamor GL provider for screen 0
The XKEYBOARD keymap compiler (xkbcomp) reports: The XKEYBOARD keymap compiler (xkbcomp) reports:
> Warning: Could not resolve keysym XF86CameraAccessEnable > Warning: Could not resolve keysym XF86CameraAccessEnable
@ -49,106 +49,80 @@ The XKEYBOARD keymap compiler (xkbcomp) reports:
> Warning: Could not resolve keysym XF86SidevuSonar > Warning: Could not resolve keysym XF86SidevuSonar
> Warning: Could not resolve keysym XF86NavInfo > Warning: Could not resolve keysym XF86NavInfo
Errors from xkbcomp are not fatal to the X server Errors from xkbcomp are not fatal to the X server
2026-10-26T03:36:19.106508Z  INFO wo::nested: XWayland ready on DISPLAY :1 2026-10-26T04:03:16.476543Z  INFO wo::nested: XWayland ready on DISPLAY :1
2026-10-26T03:36:19.108904Z  INFO wo::state: Propagated ["DISPLAY", "WAYLAND_DISPLAY"] to DBus activation environment 2026-10-26T04:03:16.478811Z  INFO wo::state: Propagated ["DISPLAY", "WAYLAND_DISPLAY"] to DBus activation environment
2026-10-26T03:36:19.112684Z  INFO wo::state: Propagated ["DISPLAY", "WAYLAND_DISPLAY"] to systemd user environment 2026-10-26T04:03:16.482425Z  INFO wo::state: Propagated ["DISPLAY", "WAYLAND_DISPLAY"] to systemd user environment
The XKEYBOARD keymap compiler (xkbcomp) reports: The XKEYBOARD keymap compiler (xkbcomp) reports:
> Warning: Unsupported maximum keycode 708, clipping. > Warning: Unsupported maximum keycode 708, clipping.
> X11 cannot support keycodes above 255. > X11 cannot support keycodes above 255.
> Warning: Virtual modifier ScrollLock multiply defined > Warning: Virtual modifier ScrollLock multiply defined
> Using 0, ignoring 0 > Using 0, ignoring 0
Errors from xkbcomp are not fatal to the X server Errors from xkbcomp are not fatal to the X server
2026-10-26T03:36:19.116806Z  INFO wo::nested: X11 window manager attached 2026-10-26T04:03:16.486053Z  INFO wo::nested: X11 window manager attached
2026-10-26T03:36:19.116829Z  INFO wo::electron: Spawning Electron for window 'main' with config: WindowConfig { name: "main", system_shell: true, url: Some("http://127.0.0.1:5173"), html: None, css: None, js: None, width: 1280, height: 800, x: 0, y: 0, z_order: -1073741824, fps: 0, format: "ARGB8888", focusable: false, interactive: false, floating: false } 2026-10-26T04:03:16.486078Z  INFO wo::electron: Spawning Electron for window 'main' with config: WindowConfig { name: "main", system_shell: true, url: Some("http://127.0.0.1:5173"), html: None, css: None, js: None, width: 1280, height: 800, x: 0, y: 0, z_order: -1073741824, fps: 0, format: "ARGB8888", focusable: false, interactive: false, floating: false }
2026-10-26T03:36:19.116868Z  INFO wo::electron: Serialized config: {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false} 2026-10-26T04:03:16.486111Z  INFO wo::electron: Serialized config: {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false}
2026-10-26T03:36:19.117076Z  INFO wo::electron: spawned Electron process window=main pid=1378527 render_node="/dev/dri/renderD128" ozone=wayland 2026-10-26T04:03:16.486335Z  INFO wo::electron: spawned Electron process window=main pid=1405653 render_node="/dev/dri/renderD128" ozone=wayland
[Wo] Electron starting, NODE_VERSION= v24.22.0 [Wo] Electron starting, NODE_VERSION= v24.22.0
[Wo] GPU backend preference: auto; Ozone platform: wayland [Wo] GPU backend preference: auto; Ozone platform: wayland
[Wo] Graphics process context: {"uid":1000,"groups":[4,24,27,30,44,46,100,117,125,995,1000],"env":{"WAYLAND_DISPLAY":"wo-0","DISPLAY":":1","XDG_SESSION_TYPE":"tty","XDG_RUNTIME_DIR":"/run/user/1000","EGL_PLATFORM":"wayland"},"renderNodeAccess":{"path":"/dev/dri/renderD128","mode":"660","readable":true,"writable":true},"switches":[{"name":"ozone-platform","present":true,"value":"wayland"},{"name":"use-gl","present":false,"value":""},{"name":"use-angle","present":false,"value":""},{"name":"disable-gpu","present":false,"value":""},{"name":"disable-vulkan","present":true,"value":""},{"name":"disable-features","present":true,"value":"Vulkan"}]} [Wo] Graphics process context: {"uid":1000,"groups":[4,24,27,30,44,46,100,117,125,995,1000],"env":{"WAYLAND_DISPLAY":"wo-0","DISPLAY":":1","XDG_SESSION_TYPE":"tty","XDG_RUNTIME_DIR":"/run/user/1000","EGL_PLATFORM":"wayland"},"renderNodeAccess":{"path":"/dev/dri/renderD128","mode":"660","readable":true,"writable":true},"switches":[{"name":"ozone-platform","present":true,"value":"wayland"},{"name":"use-gl","present":false,"value":""},{"name":"use-angle","present":false,"value":""},{"name":"disable-gpu","present":false,"value":""},{"name":"disable-vulkan","present":true,"value":""},{"name":"disable-features","present":true,"value":"Vulkan,DefaultANGLEVulkan,VulkanFromANGLE"}]}
[Wo] WO_WINDOW_CONFIG env = {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false} [Wo] WO_WINDOW_CONFIG env = {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false}
[Wo] WINDOW_CONFIG parsed = {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false} [Wo] WINDOW_CONFIG parsed = {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false}
[Wo] App ready, CLIENT_MODE= false WO_STANDALONE= undefined [Wo] App ready, CLIENT_MODE= false WO_STANDALONE= undefined
[Wo] GPU feature status: {"2d_canvas":"disabled_software","direct_rendering_display_compositor":"disabled_off_ok","gpu_compositing":"disabled_software","multiple_raster_threads":"enabled_force_on","opengl":"disabled_off","rasterization":"disabled_software","raw_draw":"enabled_on","skia_graphite":"disabled_off","trees_in_viz":"disabled_off","video_decode":"disabled_software","video_encode":"disabled_software","vulkan":"disabled_off","webgl":"disabled_off","webgpu":"disabled_off","webgpu_on_vk_via_gl_interop":"disabled_off","webnn":"disabled_off"} [Wo] GPU feature status: {"2d_canvas":"disabled_software","direct_rendering_display_compositor":"disabled_off_ok","gpu_compositing":"disabled_software","multiple_raster_threads":"enabled_force_on","opengl":"disabled_off","rasterization":"disabled_software","raw_draw":"disabled_off_ok","skia_graphite":"disabled_off","trees_in_viz":"disabled_off","video_decode":"disabled_software","video_encode":"disabled_software","vulkan":"disabled_off","webgl":"disabled_off","webgpu":"disabled_off","webgpu_on_vk_via_gl_interop":"disabled_off","webnn":"disabled_off"}
[Wo] GPU info: {"auxAttributes":{"amdSwitchable":false,"canSupportThreadedTextureMailbox":false,"glImplementationParts":"(gl=none,angle=none)","glResetNotificationStrategy":0,"hardwareSupportsVulkan":false,"inProcessGpu":true,"initializationTime":0,"isAsan":false,"isClangCoverage":false,"jpegDecodeAcceleratorSupported":false,"optimus":false,"passthroughCmdDecoder":false,"sandboxed":false,"skiaBackendType":"None","subpixelFontRendering":true,"targetCpuBits":64,"visibilityCallbackCallCount":0},"gpuDevice":[{"active":true,"deviceId":9352,"gpuPreference":0,"vendorId":4318}]} [Wo] GPU info: {"auxAttributes":{"amdSwitchable":false,"canSupportThreadedTextureMailbox":false,"glImplementationParts":"(gl=none,angle=none)","glResetNotificationStrategy":0,"hardwareSupportsVulkan":false,"inProcessGpu":true,"initializationTime":0,"isAsan":false,"isClangCoverage":false,"jpegDecodeAcceleratorSupported":false,"optimus":false,"passthroughCmdDecoder":false,"sandboxed":false,"skiaBackendType":"None","subpixelFontRendering":true,"targetCpuBits":64,"visibilityCallbackCallCount":0},"gpuDevice":[{"active":true,"deviceId":9352,"gpuPreference":0,"vendorId":4318}]}
[Wo] Offscreen shared texture: disabled (GPU compositor=disabled_software, threaded texture mailbox=false, WebGL=disabled_off) [Wo] Offscreen shared texture: disabled (GPU compositor=disabled_software, threaded texture mailbox=false, WebGL=disabled_off)
[Wo] Will connect to compositor [Wo] Will connect to compositor
[Wo] Connecting to compositor IPC: /run/user/1000/wo-ipc.sock [Wo] Connecting to compositor IPC: /run/user/1000/wo-ipc.sock
[Wo] Portal UI bridge listening at /run/user/1000/wo-portal-ui.sock [Wo] Portal UI bridge listening at /run/user/1000/wo-portal-ui.sock
2026-10-26T03:36:19.251134Z  INFO wo::electron: Electron window connected window=main 2026-10-26T04:03:16.636555Z  INFO wo::electron: Electron window connected window=main
[Wo] IPC connected, sent HELLO message [Wo] IPC connected, sent HELLO message
[Wo] Initializing GBM on render node: /dev/dri/renderD128 [Wo] Initializing GBM on render node: /dev/dri/renderD128
[Wo] DMABUF sender initialized [Wo] DMABUF sender initialized
[Wo] Creating window [Wo] Creating window
[Wo] createWindow starting [Wo] createWindow starting
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET / HTTP/1.1" 200 - 127.0.0.1 - - [26/Oct/2026 00:03:16] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /_wo.capabilities.json HTTP/1.1" 200 - 127.0.0.1 - - [26/Oct/2026 00:03:16] "GET /_wo.capabilities.json HTTP/1.1" 200 -
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /_wo.components.css HTTP/1.1" 200 - 127.0.0.1 - - [26/Oct/2026 00:03:16] "GET /_wo.components.css HTTP/1.1" 200 -
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /assets/index-HBQlw-Wm.js HTTP/1.1" 200 - 127.0.0.1 - - [26/Oct/2026 00:03:16] "GET /assets/index-CW7FlnjF.js HTTP/1.1" 200 -
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /assets/index-D7GQWtqC.css HTTP/1.1" 200 - 127.0.0.1 - - [26/Oct/2026 00:03:16] "GET /assets/index-C8Au-Yf1.css HTTP/1.1" 200 -
[Wo] Verified site rip.crit.comraw; requested capabilities: window-management, overlay-rendering, desktop-applications, xdg, dbus, portals, process-launch, system-control, notifications [Wo] Verified site rip.crit.comraw; requested capabilities: window-management, overlay-rendering, desktop-applications, xdg, dbus, portals, process-launch, system-control, notifications, settings
[Wo] Granted verified comraw system-shell capabilities [Wo] Granted verified comraw system-shell capabilities
[Wo] preloadPath=/home/hellings/.local/share/wo/electron/dist/preload.js [Wo] preloadPath=/home/hellings/.local/share/wo/electron/dist/preload.js
2026-10-26T03:36:19.290019Z  INFO wo::state: new_fractional_scale: setting preferred_scale(1.0) 2026-10-26T04:03:16.676016Z  INFO wo::state: new_fractional_scale: setting preferred_scale(1.0)
2026-10-26T03:36:19.290063Z  INFO wo::state: new_fractional_scale: done 2026-10-26T04:03:16.676055Z  INFO wo::state: new_fractional_scale: done
2026-10-26T03:36:19.290115Z  INFO wo::nested: computed and registered document and stylesheet hashes window=main page=http://127.0.0.1:5173/ 2026-10-26T04:03:16.676116Z  INFO wo::nested: computed and registered document and stylesheet hashes window=main page=http://127.0.0.1:5173/
[Wo] BrowserWindow created, CLIENT_MODE=false offscreen=true painting=true [Wo] BrowserWindow created, CLIENT_MODE=false offscreen=true painting=true
[Wo] setupIpcHandlers completed [Wo] setupIpcHandlers completed
[Wo] Loading content URL: http://127.0.0.1:5173 [Wo] Loading content URL: http://127.0.0.1:5173
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET / HTTP/1.1" 304 - 127.0.0.1 - - [26/Oct/2026 00:03:16] "GET / HTTP/1.1" 304 -
[Wo] Locking verified script http://127.0.0.1:5173/_wo.components.css -> 8337fca251ea [Wo] Locking verified script http://127.0.0.1:5173/_wo.components.css -> 8337fca251ea
[Wo] Locking verified script http://127.0.0.1:5173/assets/index-HBQlw-Wm.js -> d1647de85605 [Wo] Locking verified script http://127.0.0.1:5173/assets/index-CW7FlnjF.js -> ca1bcebf0e74
[Wo] Locking verified script http://127.0.0.1:5173/assets/index-D7GQWtqC.css -> c0e2f97ed4d1 [Wo] Locking verified script http://127.0.0.1:5173/assets/index-C8Au-Yf1.css -> 6f4280063490
[1378559:1025/233619.349346:ERROR:ui/ozone/platform/wayland/gpu/wayland_surface_factory.cc:249] '--ozone-platform=wayland' is not compatible with Vulkan. Consider switching to '--ozone-platform=x11' or disabling Vulkan
[Wo] Resource request failed: net::ERR_ABORTED stylesheet http://127.0.0.1:5173/_wo.components.css [Wo] Resource request failed: net::ERR_ABORTED stylesheet http://127.0.0.1:5173/_wo.components.css
[Wo] Resource request failed: net::ERR_ABORTED stylesheet http://127.0.0.1:5173/assets/index-D7GQWtqC.css [Wo] Resource request failed: net::ERR_ABORTED stylesheet http://127.0.0.1:5173/assets/index-C8Au-Yf1.css
[Wo] Serving verified script 8337fca251ea (2190 bytes) [Wo] Serving verified script 8337fca251ea (2190 bytes)
[Wo] Resource request failed: net::ERR_ABORTED script http://127.0.0.1:5173/assets/index-HBQlw-Wm.js [Wo] Resource request failed: net::ERR_ABORTED script http://127.0.0.1:5173/assets/index-CW7FlnjF.js
[Wo] Serving verified script c0e2f97ed4d1 (36664 bytes) [Wo] Serving verified script 6f4280063490 (37886 bytes)
[Wo] Serving verified script d1647de85605 (268639 bytes) [Wo] Serving verified script ca1bcebf0e74 (274364 bytes)
[1405690:1026/000316.820865:ERROR:ui/ozone/platform/wayland/gpu/wayland_surface_factory.cc:249] '--ozone-platform=wayland' is not compatible with Vulkan. Consider switching to '--ozone-platform=x11' or disabling Vulkan
[Wo] Content loaded from: http://127.0.0.1:5173 [Wo] Content loaded from: http://127.0.0.1:5173
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /elementary.jpg HTTP/1.1" 304 - 127.0.0.1 - - [26/Oct/2026 00:03:16] "GET /elementary.jpg HTTP/1.1" 304 -
[Wo] Page render state: {"readyState":"complete","title":"comraw","bodyText":"1\n2\n3\n4\nSun, Oct 25\n11:36 PM","bodyBackground":"rgba(0, 0, 0, 0)","bodyBackgroundImage":"none","bodyDisplay":"block","portalCardBackground":"","portalCardBounds":null,"buttons":12,"resourceConsentBridge":false,"rootChildren":1,"shellPresent":true,"wallpaperImage":"url(\"http://127.0.0.1:5173/elementary.jpg\")","compositorApi":true,"woApi":true} [Wo] Page render state: {"readyState":"complete","title":"comraw","bodyText":"1\n2\n3\n4\nMon, Oct 26\n12:03 AM","bodyBackground":"rgba(0, 0, 0, 0)","bodyBackgroundImage":"none","bodyDisplay":"block","portalCardBackground":"","portalCardBounds":null,"buttons":13,"resourceConsentBridge":false,"rootChildren":1,"shellPresent":true,"wallpaperImage":"url(\"http://127.0.0.1:5173/elementary.jpg\")","compositorApi":true,"woApi":true}
[Wo] Page initialized, setting up rendering [Wo] Page initialized, setting up rendering
[Wo] Offscreen frame pacing: 60 Hz (window cap auto) [Wo] Offscreen frame pacing: 60 Hz (window cap auto)
[Wo] createWindow completed successfully [Wo] createWindow completed successfully
[Wo] Window created successfully [Wo] Window created successfully
[Wo] Ignoring empty offscreen paint 0x0; content=1280x800 dirty=0,0,1280,800 [Wo] Ignoring empty offscreen paint 0x0; content=1280x800 dirty=0,0,1280,800
[1378559:1025/233619.594500:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface [Wo] First bitmap pixels (BGRA top-left/mid-left/center/bottom-right): [[22,17,17,255],[62,51,111,255],[77,67,127,255],[50,35,49,255]]
[1378559:1025/233619.594551:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface
[1378559:1025/233619.594579:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM
[1378559:1025/233619.594599:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM
[1378559:1025/233619.594683:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface
[1378559:1025/233619.594712:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface
[1378559:1025/233619.594738:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM
[1378559:1025/233619.594758:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM
[1378559:1025/233619.594801:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface
[1378559:1025/233619.594816:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface
[1378559:1025/233619.594828:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM
[1378559:1025/233619.594848:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM
[1378559:1025/233619.604006:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface
[1378559:1025/233619.604035:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface
[1378559:1025/233619.604047:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM
[1378559:1025/233619.604061:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM
[1378559:1025/233619.604410:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (1280, 832)
[1378559:1025/233619.607032:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (1280, 64)
[1378559:1025/233619.609323:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (192, 256)
[1378559:1025/233619.609467:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (64, 64)
[Wo] First bitmap pixels (BGRA top-left/mid-left/center/bottom-right): [[255,255,255,255],[255,255,255,255],[255,255,255,255],[255,255,255,255]]
[Wo] First bitmap frame sent as DMA-BUF: seq=1 size=1280x800 stride=5120 [Wo] First bitmap frame sent as DMA-BUF: seq=1 size=1280x800 stride=5120
[Wo] Second bitmap pixels (BGRA top-left/mid-left/center/bottom-right): [[255,255,255,255],[255,255,255,255],[255,255,255,255],[255,255,255,255]] [Wo] Second bitmap pixels (BGRA top-left/mid-left/center/bottom-right): [[22,17,17,255],[62,51,111,255],[77,67,127,255],[50,35,49,255]]
2026-10-26T04:03:17.187064Z  INFO wo::nested: first Electron frame imported as a DMA-BUF texture window=main width=1280 height=800 seq=1
2026-10-26T04:03:17.187588Z  INFO wo::cursor: Loaded cursor 'default' (24x24, hotspot 3,1) from /usr/share/icons/Adwaita/cursors/default
2026-10-26T04:03:17.188289Z  INFO wo::nested: first Electron texture rendered into the output frame window=main x=0 y=0 output_width=1280 output_height=800
[Wo] SKIPPED paint due to frame backpressure inFlight=2 [Wo] SKIPPED paint due to frame backpressure inFlight=2
2026-10-26T03:36:19.634388Z  INFO wo::nested: first Electron frame imported as a DMA-BUF texture window=main width=1280 height=800 seq=1 2026-10-26T04:03:17.218902Z  INFO wo::nested: first nested output frame submitted successfully
2026-10-26T03:36:19.634880Z  INFO wo::cursor: Loaded cursor 'default' (24x24, hotspot 3,1) from /usr/share/icons/Adwaita/cursors/default
2026-10-26T03:36:19.635561Z  INFO wo::nested: first Electron texture rendered into the output frame window=main x=0 y=0 output_width=1280 output_height=800
2026-10-26T03:36:19.663496Z  INFO wo::nested: first nested output frame submitted successfully
[1378559:1025/233619.899568:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface
[1378559:1025/233619.899611:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface
[1378559:1025/233619.899629:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM
[1378559:1025/233619.899638:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM
[1378559:1025/233619.900098:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (1280, 832)
[Wo] Load timeout, continuing
Serving HTTP on 127.0.0.1 port 5173 (http://127.0.0.1:5173/) ... Serving HTTP on 127.0.0.1 port 5173 (http://127.0.0.1:5173/) ...
Keyboard interrupt received, exiting. Keyboard interrupt received, exiting.
[Wo] IPC connection closed [Wo] IPC connection closed
Gdk-Message: 23:36:25.499: Error reading events from display: Broken pipe Gdk-Message: 00:03:23.498: Error reading events from display: Broken pipe