UNPKG

dbus-sdk

Version:

A Node.js SDK for interacting with DBus, enabling seamless service calling and exposure with TypeScript support

324 lines (323 loc) 16.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.LocalService = void 0; const DBus_1 = require("./DBus"); const Errors_1 = require("./lib/Errors"); const IntrospectableInterface_1 = require("./lib/common/IntrospectableInterface"); const DBusSignedValue_1 = require("./lib/DBusSignedValue"); const CreateDBusError_1 = require("./lib/CreateDBusError"); const RootObject_1 = require("./lib/common/RootObject"); const RequestNameFlags_1 = require("./lib/enums/RequestNameFlags"); /** * A class representing a local DBus service. * This class manages a collection of objects and their associated interfaces within a DBus service. * It handles connecting to a DBus bus, processing incoming method calls, and managing the lifecycle * of the service. It serves as the top-level entity for a local DBus service implementation. */ class LocalService { /** * A regular expression for validating DBus error names. * Ensures error names follow the DBus naming convention (e.g., 'org.example.ErrorName'). */ #errorNameRegex = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/; /** * The name of this service, adhering to DBus naming conventions. * This uniquely identifies the service on the bus (e.g., 'org.example.Service'). */ #name; /** * A map of object paths to their corresponding LocalObject instances. * Stores all objects associated with this service for quick lookup and management. */ #objectMap = new Map(); /** * A default IntrospectableInterface instance for handling introspection requests. * Used when a specific object or interface is not found but introspection is requested. */ #defaultIntrospectableInterface = new IntrospectableInterface_1.IntrospectableInterface(); /** * Getter for the ObjectManager interface associated with this service. * Provides access to the 'org.freedesktop.DBus.ObjectManager' interface on the root object, * if available, for managing object hierarchies. * * @returns The ObjectManagerInterface instance if found on the root object, otherwise undefined. */ get objectManager() { return this.findObjectByPath('/')?.findInterfaceByName('org.freedesktop.DBus.ObjectManager'); } /** * Getter for the name of this local service. * Returns the validated service name set during construction. * * @returns The service name as a string (e.g., 'org.example.Service'). */ get name() { return this.#name; } /** * Constructor for LocalService. * Initializes the service with a validated service name and adds a root object * to serve as the base of the object hierarchy. * * @param serviceName - The DBus service name to be validated and set (e.g., 'org.example.Service'). * @throws {LocalServiceInvalidNameError} If the provided name does not meet DBus naming criteria. */ constructor(serviceName) { this.#name = this.validateDBusServiceName(serviceName); this.addObject(new RootObject_1.RootObject()); // Add root object as the base of the hierarchy } /** * Handler for incoming DBus method call messages. * Processes method calls by routing them to the appropriate object and interface, * executing the method, and sending a reply (success or error) back to the caller. * Falls back to introspection handling if the target interface and method are for introspection * but the specific object or interface is not found. * * @param message - The DBusMessage containing the method call details (path, interface, method, etc.). * @returns A Promise that resolves when the method call is processed and a reply is sent. * @private */ #methodCallHandler = async (message) => { const targetObjectPath = message.header.path; const targetInterface = message.header.interfaceName; const targetMethod = message.header.member; const payloadSignature = message.header.signature; const localObject = this.findObjectByPath(targetObjectPath); if (localObject) { const localInterface = localObject.findInterfaceByName(targetInterface); if (localInterface) { try { const { signature, result } = await localInterface.callMethod(targetMethod, payloadSignature, ...message.body); const resultSignedValue = signature ? [new DBusSignedValue_1.DBusSignedValue(signature, result)] : []; return this.dbus.reply({ destination: message.header.sender, replySerial: message.header.serial, signature: signature, data: resultSignedValue }); } catch (e) { return this.dbus.reply({ destination: message.header.sender, replySerial: message.header.serial, signature: 's', data: this.formatDBusError(e instanceof Error ? e : new Error(e.toString())) }); } } } /** * Introspect */ if (targetInterface === 'org.freedesktop.DBus.Introspectable' && targetMethod === 'Introspect') { return this.dbus.reply({ destination: message.header.sender, replySerial: message.header.serial, signature: 's', data: [this.#defaultIntrospectableInterface.formatIntrospectXML(targetObjectPath, this.listObjectPaths())] }); } // If object or interface not found, reply with an error return this.dbus.reply({ destination: message.header.sender, replySerial: message.header.serial, data: (0, CreateDBusError_1.CreateDBusError)('org.freedesktop.DBus.Error.UnknownObject', `Object path ${message.header.path} not found`) }); }; /** * Validates a DBus service name based on DBus naming rules. * Ensures the name is a non-empty string, within length limits, contains at least two elements * separated by dots, does not start or end with a dot, avoids consecutive dots, and uses * only allowed characters (letters, digits, underscores, hyphens) in each element. * * @param serviceName - The name to validate. * @returns The validated service name if it passes all checks. * @throws {LocalServiceInvalidNameError} If the name does not meet DBus naming criteria. */ validateDBusServiceName(serviceName) { // Step 1: Check if the input is a string and not empty if (typeof serviceName !== 'string' || serviceName.length === 0) { throw new Errors_1.LocalServiceInvalidNameError('Service name must be a non-empty string.'); } // Step 2: Check length limit (maximum 255 bytes as per DBus spec) if (serviceName.length > 255) { throw new Errors_1.LocalServiceInvalidNameError('Service name exceeds 255 bytes.'); } // Step 3: Check if it starts or ends with a dot, or contains consecutive dots if (serviceName.startsWith('.')) { throw new Errors_1.LocalServiceInvalidNameError('Service name cannot start with a dot.'); } if (serviceName.endsWith('.')) { throw new Errors_1.LocalServiceInvalidNameError('Service name cannot end with a dot.'); } if (serviceName.includes('..')) { throw new Errors_1.LocalServiceInvalidNameError('Service name cannot contain consecutive dots.'); } // Step 4: Split the service name into elements and check if there are at least 2 elements const elements = serviceName.split('.'); if (elements.length < 2) { throw new Errors_1.LocalServiceInvalidNameError('Service name must have at least two elements separated by dots.'); } // Step 5: Validate each element for allowed characters and structure for (let i = 0; i < elements.length; i++) { const element = elements[i]; // Check if element is empty if (element.length === 0) { throw new Errors_1.LocalServiceInvalidNameError(`Element at position ${i + 1} is empty.`); } // Check if element starts with a hyphen if (element.startsWith('-')) { throw new Errors_1.LocalServiceInvalidNameError(`Element "${element}" at position ${i + 1} cannot start with a hyphen.`); } // Check if element contains only allowed characters (letters, digits, underscore, hyphen) for (let j = 0; j < element.length; j++) { const char = element[j]; if (!/[a-zA-Z0-9_-]/.test(char)) { throw new Errors_1.LocalServiceInvalidNameError(`Element "${element}" at position ${i + 1} contains invalid character "${char}".`); } } } // All checks passed, return the service name return serviceName; } /** * Formats an error to ensure it has a valid DBus error name. * Appends the service name as a prefix if the error name does not match DBus naming conventions. * If the error name still doesn't match after prefixing, defaults to a generic error name with the service prefix. * * @param error - The error to format. * @returns The formatted error with a valid DBus error name (e.g., 'org.example.Service.Error'). */ formatDBusError(error) { if (!this.#errorNameRegex.test(error.name)) { error.name = `${this.#name}.${error.name}`; if (!this.#errorNameRegex.test(error.name)) error.name = `${this.#name}.Error`; } return error; } /** * Connects to a DBus bus and starts the service. * Establishes a connection to the bus using the provided options, registers the method call handler * to process incoming requests, and requests ownership of the service name on the bus to make it * available for clients to interact with, using configurable flags for name request behavior. * * @param opts - Connection options for the DBus bus (e.g., socket path, TCP details) and optional flags for name request behavior. * @returns A Promise that resolves to a RequestNameResultCode indicating the result of the service name request. */ async run(opts) { const flags = opts.flags !== undefined ? opts.flags : RequestNameFlags_1.RequestNameFlags.DBUS_NAME_FLAG_DEFAULT; this.dbus = await DBus_1.DBus.connect(opts); // Connect to the DBus bus this.dbus.on('methodCall', this.#methodCallHandler); // Register handler for incoming method calls return await this.dbus.requestName(this.#name, flags); // Request ownership of the service name with specified flags } /** * Stops the service and disconnects from the DBus bus. * Releases ownership of the service name to allow other services to claim it, removes the method * call handler to stop processing requests, and closes the connection to the bus to clean up resources. * * @returns A Promise that resolves when the service is stopped and disconnected from the bus. */ async stop() { await this.dbus.releaseName(this.#name); // Release ownership of the service name this.dbus.off('methodCall', this.#methodCallHandler); // Remove the method call handler await this.dbus.disconnect(); // Disconnect from the bus } /** * Adds a LocalObject to this service. * Associates the object with this service, linking it to the service's context for further operations, * and notifies the object manager of the addition if an ObjectManager interface is available on the root object. * * @param localObject - The LocalObject instance to add to this service. * @returns A boolean indicating whether the object was successfully added (true if added, false if already present). * @throws {LocalObjectPathExistsError} If an object with the same path already exists and is not the same instance. */ addObject(localObject) { let addSuccess = false; if (this.#objectMap.has(localObject.name)) { if (this.#objectMap.get(localObject.name) !== localObject) { throw new Errors_1.LocalObjectPathExistsError(`Local object path ${localObject.name} exists`); } else { return addSuccess; // Object already exists and is the same instance, no action needed } } localObject.setService(this); // Link the object to this service this.#objectMap.set(localObject.name, localObject); addSuccess = true; if (addSuccess) this.objectManager?.interfacesAdded(localObject, localObject.getManagedInterfaces()); return addSuccess; } /** * Removes a LocalObject from this service by instance or object path. * This method handles both string (object path) and LocalObject instance as input, * unlinking the object from the service and notifying the object manager of the removal * if an ObjectManager interface is available on the root object. * * @param inp - The object path or the LocalObject instance to remove. * @returns A boolean indicating whether the object was successfully removed (true if removed, false if not found). */ removeObject(inp) { let removeSuccess; let removedObject; if (typeof inp === 'string') { // Case 1: Input is a string representing the object path. // Attempts to find and unset the associated service before deleting the object. this.#objectMap.get(inp)?.setService(undefined); removedObject = this.#objectMap.get(inp); removeSuccess = this.#objectMap.delete(inp); } else { // Case 2: Input is a LocalObject instance. // Finds the object by instance, unsets the associated service, and deletes it. const result = [...this.#objectMap.entries()].find(([localObjectPath, localObject]) => localObject === inp); if (!result) { removeSuccess = false; } else { result[1].setService(undefined); removedObject = result[1]; removeSuccess = this.#objectMap.delete(result[0]); } } const removedInterfaceNames = removedObject?.interfaceNames(); // If removal was successful, notify the object manager of the removed interfaces if (removedObject && removeSuccess) this.objectManager?.interfacesRemoved(removedObject, removedInterfaceNames ? removedInterfaceNames : []); return removeSuccess; } /** * Lists all objects associated with this service. * Provides a convenient way to inspect all objects currently linked to the service by returning * a record mapping object paths to their respective LocalObject instances. * * @returns A record mapping object paths to their LocalObject instances. */ listObjects() { const objects = {}; this.#objectMap.forEach((localObject, objectPath) => objects[objectPath] = localObject); return objects; } /** * Finds a LocalObject by its path. * Allows retrieval of a specific object by its object path with type casting for specialized object types. * * @param objectPath - The path of the object to find (e.g., '/org/example/Object'). * @returns The LocalObject instance of the specified type if found, otherwise undefined. * @template T - The type of LocalObject to cast the result to (defaults to LocalObject). */ findObjectByPath(objectPath) { return this.#objectMap.get(objectPath); } /** * Lists all object paths associated with this service. * Provides a quick way to retrieve just the paths of the objects for enumeration purposes. * * @returns An array of object paths as strings. */ listObjectPaths() { return [...this.#objectMap.keys()]; } } exports.LocalService = LocalService;