// SPDX-License-Identifier: AGPL-3.0-or-later /* * Shared plumbing for the DOM D-Bus bridge (`navigator.embedder.dbus`, see * source/components/script/dom/dbus.rs). * * Bridge contract, as implemented in dbus.rs: * - `call` / `getProperty` resolve with a JSON *string*; parse it. * - D-Bus -> JSON: object path -> string, `ao` -> string[], `ay` -> number[], * `a{sv}` -> object, variants unwrapped, numbers and bools as-is. * - Outbound args are typed from the JS values (bool/int/double/string) * unless a `signature` is given, in which case container args are passed as * JSON strings and expanded per the signature. Variants can be tagged * `{$sig, $val}`. * - `subscribe` takes an optional `path`, which narrows the *bus* match only; * dispatch to JS is still by member name, so client-side filtering stays * necessary. */ export const SYSTEM = "system"; export const SESSION = "session"; export const PROPS_IFACE = "org.freedesktop.DBus.Properties"; /** * A verbose log channel, off by default. Each D-Bus module gets its own so one * can be turned on without the others. Enable before load with * `localStorage.setItem("beaver.debug.", "1")`, or at runtime with * `window.__Debug = true`. */ export function makeDebugLog(tag, key) { const flag = `__${key}Debug`; const stored = `beaver.debug.${key}`; return (...args) => { let enabled = globalThis[flag]; if (enabled === undefined) { try { enabled = localStorage.getItem(stored) === "1"; } catch { enabled = false; } } if (enabled) { console.log(`[${tag}]`, ...args); } }; } // This module's own channel: the hub's signal routing. export const dbusLog = makeDebugLog("dbus", "dbus"); // The DOM D-Bus object, or throw a clear error if this page cannot reach it. export function bus() { const dbus = globalThis.navigator?.embedder?.dbus; if (!dbus) { throw new Error( "navigator.embedder.dbus unavailable: needs a beaver:// page on a " + "D-Bus (Linux) build", ); } return dbus; } // True when this page can reach the bridge at all. export function busAvailable() { return !!globalThis.navigator?.embedder?.dbus; } // `call` / `getProperty` resolve with a JSON string ("null" when empty). export function parseReply(json) { return json == null || json === "" ? null : JSON.parse(json); } /** * Call a method. Args are limited to bool/int/double/string unless a D-Bus * `signature` is given, in which case container args (dicts, arrays, object * paths) are passed as JSON strings and expanded per the signature. */ export async function callMethod({ bus: busName = SYSTEM, destination, path, iface, method, args = [], signature = "", }) { const json = await bus().call({ bus: busName, destination, path, interfaceName: iface, method, args, signature, }); return parseReply(json); } // Read one property; the bridge has already unwrapped the variant. export async function getProperty({ bus: busName = SYSTEM, destination, path, iface, property, }) { const json = await bus().getProperty({ bus: busName, destination, path, interfaceName: iface, property, }); return parseReply(json); } // Write one primitive property value. export function setProperty( { bus: busName = SYSTEM, destination, path, iface, property }, value, ) { return bus().setProperty( { bus: busName, destination, path, interfaceName: iface, property, }, value, ); } /** * Read every property of an interface in one round trip * (`a{sv}` -> object). */ export async function getAllProperties({ bus: busName = SYSTEM, destination, path, iface, }) { return ( (await callMethod({ bus: busName, destination, path, iface: PROPS_IFACE, method: "GetAll", args: [iface], })) || {} ); } /** * Routes incoming signals to handlers, and ref-counts the underlying bus * subscriptions. * * Two layers, because the bridge collapses signals onto the member name: * one DOM listener per member name, fanning out to the handlers whose interface * and path match; and one bus subscription per (bus, interface, member), shared * by every handler that wants it. */ class SignalHub { constructor() { this._entries = new Map(); // member -> Set<{iface, path, handler}> this._routers = new Map(); // member -> the addEventListener callback this._subs = new Map(); // "bus|iface|member" -> {count, idPromise} } /** * Route a signal to `handler(detail)` when it matches `iface` and optional * `path`. Returns an unsubscribe function with a `ready` promise attached, * which resolves once the underlying bus subscription actually exists. */ on(busName, iface, member, path, handler) { const entry = { iface, path: path || "", handler }; let set = this._entries.get(member); if (!set) { set = new Set(); this._entries.set(member, set); // One DOM listener per member name; it fans out to matching entries. const router = (event) => { const detail = event.detail || {}; dbusLog("signal", member, { interface: detail.interface, path: detail.path, args: detail.args, listeners: set.size, }); for (const en of set) { if (en.iface && detail.interface !== en.iface) { continue; } if (en.path && detail.path !== en.path) { continue; } try { en.handler(detail); } catch (err) { console.error("[dbus] signal handler failed:", err); } } }; this._routers.set(member, router); bus().addEventListener(member, router); } set.add(entry); // One bus subscription per (bus, interface, member), ref-counted. const key = `${busName}|${iface}|${member}`; let sub = this._subs.get(key); if (!sub) { sub = { count: 0, idPromise: bus().subscribe({ bus: busName, interfaceName: iface, signal: member, }), }; dbusLog("subscribe ->", key); sub.idPromise.then( (id) => dbusLog("subscribed", key, "id=" + id), (err) => console.warn("[dbus] subscribe FAILED", key, err), ); // No unhandled rejection if the subscribe itself fails. sub.idPromise.catch(() => {}); this._subs.set(key, sub); } sub.count += 1; let active = true; const unsubscribe = () => { if (!active) { return; } active = false; set.delete(entry); if (set.size === 0) { this._entries.delete(member); const router = this._routers.get(member); if (router) { bus().removeEventListener(member, router); this._routers.delete(member); } } sub.count -= 1; if (sub.count <= 0) { this._subs.delete(key); sub.idPromise.then((id) => bus().unsubscribe(id)).catch(() => {}); } }; // Resolves when the bus subscription is live, so callers can avoid racing // a signal they are about to provoke. Never rejects: a failed subscribe // just means the handler won't fire, which callers already tolerate. unsubscribe.ready = sub.idPromise.then( () => {}, () => {}, ); return unsubscribe; } } // The one hub every D-Bus consumer shares. export const hub = new SignalHub(); // Look up an enum member name by value, for logging and debugging. export function enumName(enumObject, value) { for (const [key, candidate] of Object.entries(enumObject)) { if (candidate === value) { return key; } } return String(value); }