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
JavaScript
"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;