UNPKG

dbus-sdk

Version:

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

588 lines (587 loc) 30 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.LocalInterface = void 0; const DBusPropertyAccess_1 = require("./lib/enums/DBusPropertyAccess"); const Errors_1 = require("./lib/Errors"); const DBusSignedValue_1 = require("./lib/DBusSignedValue"); const CreateDBusError_1 = require("./lib/CreateDBusError"); const Signature_1 = require("./lib/Signature"); const DBusBufferEncoder_1 = require("./lib/DBusBufferEncoder"); const DBusBufferDecoder_1 = require("./lib/DBusBufferDecoder"); /** * A class representing a local DBus interface. * This class enables the definition of methods, properties, and signals for a local DBus service, * facilitating the creation of custom interfaces. It manages name validation, introspection data, * and interactions with DBus for handling method calls, property access, and signal emission. */ class LocalInterface { /** * The name of the interface, adhering to DBus naming conventions. * This uniquely identifies the interface within a service (e.g., 'org.example.MyInterface'). */ #name; /** * An array of IntrospectMethod objects for introspection. * Stores metadata about defined methods for generating introspection XML as per DBus specification. */ #introspectMethods = []; /** * A record of defined methods on this interface. * Maps method names to their input/output signatures and implementation functions for execution. */ #definedMethods = {}; /** * An array of IntrospectProperty objects for introspection. * Stores metadata about defined properties for generating introspection XML as per DBus specification. */ #introspectProperties = []; /** * A record of defined properties on this interface. * Maps property names to their signatures, getter, and setter functions for access and modification. */ #definedProperties = {}; /** * An array of IntrospectSignal objects for introspection. * Stores metadata about defined signals for generating introspection XML as per DBus specification. */ #introspectSignals = []; /** * A record of defined signals on this interface. * Maps signal names to their listener functions and associated EventEmitter instances for emission. */ #definedSignals = {}; /** * An array of records for properties whose changes should emit values. * Stores temporary records of changed properties with their new values to be included in the 'PropertiesChanged' signal. */ #propertiesEmitValueChanges = []; /** * An array of property names whose changes should not emit values. * Stores temporary names of properties that are invalidated (changed without including new values) in the 'PropertiesChanged' signal. */ #propertiesNotEmitValueChanges = []; /** * Handles property change notifications by emitting the 'PropertiesChanged' signal. * Combines accumulated property changes (with and without values) into a single signal emission * if there are changes to report and a PropertiesInterface is available on the associated object. * Resets the accumulated change records after emission. * * @private */ #propertyChangeHandler() { if (!this.#propertiesEmitValueChanges.length && !this.#propertiesNotEmitValueChanges.length) return; const propertiesInterface = this.object?.propertiesInterface; if (!propertiesInterface) return; const changedProperties = this.#propertiesEmitValueChanges.reduce((previousValue, currentValue) => ({ ...previousValue, ...currentValue }), {}); const changedPropertyNames = [...new Set(this.#propertiesNotEmitValueChanges)]; this.#propertiesEmitValueChanges = []; this.#propertiesNotEmitValueChanges = []; propertiesInterface.emitPropertiesChanged(this.#name, changedProperties, changedPropertyNames); } /** * Getter for the DBus instance associated with this interface's object. * Provides access to the DBus connection for operations such as emitting signals or sending messages. * * @returns The DBus instance if the associated object is defined and connected, otherwise undefined. */ get dbus() { if (!this.object) return; return this.object.dbus; } /** * Getter for the name of this interface. * Returns the validated interface name set during construction. * * @returns The interface name as a string (e.g., 'org.example.MyInterface'). */ get name() { return this.#name; } /** * Constructor for LocalInterface. * Initializes the interface with a validated name, ensuring it adheres to DBus naming rules * as specified in the DBus protocol documentation. * * @param interfaceName - The name of the interface to be validated and set (e.g., 'org.example.MyInterface'). * @throws {LocalInterfaceInvalidNameError} If the provided name does not meet DBus naming criteria. */ constructor(interfaceName) { this.#name = this.validateDBusInterfaceName(interfaceName); } /** * Validates a DBus interface name based on DBus naming rules. * Ensures the name is a non-empty string, within length limits (255 bytes), 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) in each element. * * @param interfaceName - The name to validate. * @returns The validated interface name if it passes all checks. * @throws {LocalInterfaceInvalidNameError} If the name does not meet DBus naming criteria. */ validateDBusInterfaceName(interfaceName) { // Step 1: Check if the input is a string and not empty if (typeof interfaceName !== 'string' || interfaceName.length === 0) { throw new Errors_1.LocalInterfaceInvalidNameError('Interface name must be a non-empty string.'); } // Step 2: Check length limit (maximum 255 bytes as per DBus spec) if (interfaceName.length > 255) { throw new Errors_1.LocalInterfaceInvalidNameError('Interface name exceeds 255 bytes.'); } // Step 3: Check if it starts or ends with a dot, or contains consecutive dots if (interfaceName.startsWith('.')) { throw new Errors_1.LocalInterfaceInvalidNameError('Interface name cannot start with a dot.'); } if (interfaceName.endsWith('.')) { throw new Errors_1.LocalInterfaceInvalidNameError('Interface name cannot end with a dot.'); } if (interfaceName.includes('..')) { throw new Errors_1.LocalInterfaceInvalidNameError('Interface name cannot contain consecutive dots.'); } // Step 4: Split the interface name into elements and check if there are at least 2 elements const elements = interfaceName.split('.'); if (elements.length < 2) { throw new Errors_1.LocalInterfaceInvalidNameError('Interface 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.LocalInterfaceInvalidNameError(`Element at position ${i + 1} is empty.`); } // Check if element starts with a digit if (element.match(/^[0-9]/)) { throw new Errors_1.LocalInterfaceInvalidNameError(`Element "${element}" at position ${i + 1} cannot start with a digit.`); } // Check if element contains only allowed characters (letters, digits, underscore) for (let j = 0; j < element.length; j++) { const char = element[j]; if (!/[a-zA-Z0-9_]/.test(char)) { throw new Errors_1.LocalInterfaceInvalidNameError(`Element "${element}" at position ${i + 1} contains invalid character "${char}".`); } } } // All checks passed, return the interface name return interfaceName; } /** * Validates a DBus method name based on DBus naming rules. * Ensures the name is a non-empty string, within length limits (255 bytes), does not start with a digit, * and uses only allowed characters (letters, digits, underscores) as per DBus conventions. * * @param methodName - The name to validate. * @returns The validated method name if it passes all checks. * @throws {LocalInterfaceInvalidMethodNameError} If the name does not meet DBus naming criteria. */ validateDBusMethodName(methodName) { // Step 1: Check if the input is a string and not empty if (typeof methodName !== 'string' || methodName.length === 0) { throw new Errors_1.LocalInterfaceInvalidMethodNameError('Method name must be a non-empty string.'); } // Step 2: Check length limit (maximum 255 bytes, consistent with other DBus name limits) if (methodName.length > 255) { throw new Errors_1.LocalInterfaceInvalidMethodNameError('Method name exceeds 255 bytes.'); } // Step 3: Check if it starts with a digit if (methodName.match(/^[0-9]/)) { throw new Errors_1.LocalInterfaceInvalidMethodNameError('Method name cannot start with a digit.'); } // Step 4: Check if it contains only allowed characters (letters, digits, underscore) for (let i = 0; i < methodName.length; i++) { const char = methodName[i]; if (!/[a-zA-Z0-9_]/.test(char)) { throw new Errors_1.LocalInterfaceInvalidMethodNameError(`Method name contains invalid character "${char}".`); } } // All checks passed, return the method name return methodName; } /** * Validates a DBus property name based on DBus naming rules. * Ensures the name is a non-empty string, within length limits (255 bytes), does not start with a digit, * and uses only allowed characters (letters, digits, underscores) as per DBus conventions. * * @param propertyName - The name to validate. * @returns The validated property name if it passes all checks. * @throws {LocalInterfaceInvalidPropertyNameError} If the name does not meet DBus naming criteria. */ validateDBusPropertyName(propertyName) { // Step 1: Check if the input is a string and not empty if (typeof propertyName !== 'string' || propertyName.length === 0) { throw new Errors_1.LocalInterfaceInvalidPropertyNameError('Property name must be a non-empty string.'); } // Step 2: Check length limit (maximum 255 bytes, consistent with other DBus name limits) if (propertyName.length > 255) { throw new Errors_1.LocalInterfaceInvalidPropertyNameError('Property name exceeds 255 bytes.'); } // Step 3: Check if it starts with a digit if (propertyName.match(/^[0-9]/)) { throw new Errors_1.LocalInterfaceInvalidPropertyNameError('Property name cannot start with a digit.'); } // Step 4: Check if it contains only allowed characters (letters, digits, underscore) for (let i = 0; i < propertyName.length; i++) { const char = propertyName[i]; if (!/[a-zA-Z0-9_]/.test(char)) { throw new Errors_1.LocalInterfaceInvalidPropertyNameError(`Property name contains invalid character "${char}".`); } } // All checks passed, return the property name return propertyName; } /** * Validates a DBus signal name based on DBus naming rules. * Ensures the name is a non-empty string, within length limits (255 bytes), does not start with a digit, * and uses only allowed characters (letters, digits, underscores) as per DBus conventions. * * @param signalName - The name to validate. * @returns The validated signal name if it passes all checks. * @throws {LocalInterfaceInvalidSignalNameError} If the name does not meet DBus naming criteria. */ validateDBusSignalName(signalName) { // Step 1: Check if the input is a string and not empty if (typeof signalName !== 'string' || signalName.length === 0) { throw new Errors_1.LocalInterfaceInvalidSignalNameError('Signal name must be a non-empty string.'); } // Step 2: Check length limit (maximum 255 bytes, consistent with other DBus name limits) if (signalName.length > 255) { throw new Errors_1.LocalInterfaceInvalidSignalNameError('Signal name exceeds 255 bytes.'); } // Step 3: Check if it starts with a digit if (signalName.match(/^[0-9]/)) { throw new Errors_1.LocalInterfaceInvalidSignalNameError('Signal name cannot start with a digit.'); } // Step 4: Check if it contains only allowed characters (letters, digits, underscore) for (let i = 0; i < signalName.length; i++) { const char = signalName[i]; if (!/[a-zA-Z0-9_]/.test(char)) { throw new Errors_1.LocalInterfaceInvalidSignalNameError(`Signal name contains invalid character "${char}".`); } } // All checks passed, return the signal name return signalName; } /** * Sets the LocalObject associated with this interface. * Links the interface to a specific object within a DBus service, providing context for operations * such as signal emission or property access on a specific object path. * * @param localObject - The LocalObject to associate with this interface, or undefined to clear the association. * @returns The instance of this LocalInterface for method chaining. */ setObject(localObject) { this.object = localObject; return this; } /** * Getter for the introspection data of this interface. * Provides metadata about the interface's methods, properties, and signals in a format suitable * for DBus introspection, allowing clients to discover the capabilities of this interface. * * @returns An IntrospectInterface object containing the name, methods, properties, and signals defined for this interface. */ get introspectInterface() { return { name: this.#name, method: this.#introspectMethods, property: this.#introspectProperties, signal: this.#introspectSignals }; } /** * Defines a new method for this interface. * Configures a method with specified input and output arguments and an implementation function, * validates the method name against DBus naming rules, and updates introspection data for discovery. * * @param opts - Options for defining the method, including name, input/output arguments, and the method implementation. * @returns The instance of this LocalInterface for method chaining. * @throws {LocalInterfaceMethodDefinedError} If a method with the same name is already defined. * @throws {LocalInterfaceInvalidMethodNameError} If the method name does not meet DBus naming criteria. */ defineMethod(opts) { if (this.#definedMethods[opts.name]) throw new Errors_1.LocalInterfaceMethodDefinedError(`Method ${opts.name} is already defined`); opts.name = this.validateDBusMethodName(opts.name); this.#definedMethods[opts.name] = { inputSignature: opts.inputArgs ? opts.inputArgs.map((inputArg) => inputArg.type).join('') : undefined, outputSignature: opts.outputArgs ? opts.outputArgs.map((outputArg) => outputArg.type).join('') : undefined, method: opts.method }; this.#introspectMethods.push({ name: opts.name, arg: [ ...(opts.inputArgs ? opts.inputArgs.map((inputArg) => ({ name: inputArg.name, type: inputArg.type, direction: 'in' })) : []), ...(opts.outputArgs ? opts.outputArgs.map((outputArg) => ({ name: outputArg.name, type: outputArg.type, direction: 'out' })) : []) ] }); return this; } /** * Removes a defined method from this interface. * Deletes the method from the internal record and removes its associated introspection data. * * @param name - The name of the method to remove. * @returns The instance of this LocalInterface for method chaining. */ removeMethod(name) { delete this.#definedMethods[name]; this.#introspectMethods = this.#introspectMethods.filter((introspectMethod) => introspectMethod.name !== name); return this; } /** * Defines a new property for this interface. * Configures a property with a specified type, determines access mode (read, write, read-write) based on provided * getter/setter functions, and supports emitting property change signals if configured via options. * Updates introspection data for discovery and sets up asynchronous change notifications via setImmediate. * * @param opts - Options for defining the property, including name, type, access mode, getter/setter functions, and change emission settings. * @returns The instance of this LocalInterface for method chaining. * @throws {LocalInterfacePropertyDefinedError} If a property with the same name is already defined. * @throws {LocalInterfaceInvalidPropertyNameError} If the property name does not meet DBus naming criteria. */ defineProperty(opts) { if (!opts.setter && !opts.getter) return this; // Skip if neither getter nor setter is provided if (this.#definedProperties[opts.name]) throw new Errors_1.LocalInterfacePropertyDefinedError(`Property ${opts.name} is already defined`); let access = DBusPropertyAccess_1.DBusPropertyAccess.READWRITE; if (opts.getter) access = DBusPropertyAccess_1.DBusPropertyAccess.READ; if (opts.setter) access = DBusPropertyAccess_1.DBusPropertyAccess.WRITE; if (opts.getter && opts.setter) access = DBusPropertyAccess_1.DBusPropertyAccess.READWRITE; opts.name = this.validateDBusPropertyName(opts.name); const getter = opts.getter; let setter = undefined; if (opts.emitPropertiesChanged && opts.setter) { if ((typeof opts.emitPropertiesChanged === 'boolean' && opts.emitPropertiesChanged) || opts.emitPropertiesChanged.emitValue) { // Emit property changed signal with the new value setter = (value) => { opts.setter(value); const changedProperties = {}; changedProperties[opts.name] = this.getPropertySignedValue(opts.name); this.#propertiesEmitValueChanges.push(changedProperties); setImmediate(() => this.#propertyChangeHandler()); }; } else { // Emit property changed signal without the new value (invalidated only) setter = (value) => { opts.setter(value); this.#propertiesNotEmitValueChanges.push(opts.name); setImmediate(() => this.#propertyChangeHandler()); }; } } this.#definedProperties[opts.name] = { signature: opts.type, getter: getter, setter: setter }; this.#introspectProperties.push({ name: opts.name, type: opts.type, access: access }); return this; } /** * Removes a defined property from this interface. * Deletes the property from the internal record and removes its associated introspection data. * * @param name - The name of the property to remove. * @returns The instance of this LocalInterface for method chaining. */ removeProperty(name) { delete this.#definedProperties[name]; this.#introspectProperties = this.#introspectProperties.filter((introspectProperty) => introspectProperty.name !== name); return this; } /** * Defines a new signal for this interface. * Configures a signal with specified arguments and associates it with an EventEmitter for emission. * When the signal is triggered, it is sent over the DBus connection if the interface is associated * with an object and a DBus connection is available. * * @param opts - Options for defining the signal, including name, arguments, and associated event emitter. * @returns The instance of this LocalInterface for method chaining. * @throws {LocalInterfaceSignalDefinedError} If a signal with the same name is already defined. * @throws {LocalInterfaceInvalidSignalNameError} If the signal name does not meet DBus naming criteria. */ defineSignal(opts) { if (this.#definedSignals[opts.name]) throw new Errors_1.LocalInterfaceSignalDefinedError(`Signal ${opts.name} is already defined`); const signature = opts.args?.map((arg) => arg.type).join(''); opts.name = this.validateDBusSignalName(opts.name); this.#definedSignals[opts.name] = { listener: (...args) => { if (!this.dbus || !this.object) return; // Skip if DBus or object context is not available this.dbus.emitSignal({ objectPath: this.object.name, interface: this.#name, signal: opts.name, signature: signature, data: args }); }, eventEmitter: opts.eventEmitter }; this.#introspectSignals.push({ name: opts.name, arg: opts.args ? opts.args : [] }); // Register the listener with the event emitter to handle signal emissions this.#definedSignals[opts.name].eventEmitter.on(opts.name, this.#definedSignals[opts.name].listener); return this; } /** * Removes a defined signal from this interface. * Deletes the signal from the internal record, removes its listener from the associated event emitter, * and updates the introspection data by removing the signal's metadata. * * @param name - The name of the signal to remove. * @returns The instance of this LocalInterface for method chaining. */ removeSignal(name) { if (this.#definedSignals[name]) this.#definedSignals[name].eventEmitter.removeListener(name, this.#definedSignals[name].listener); delete this.#definedSignals[name]; this.#introspectSignals = this.#introspectSignals.filter((introspectSignal) => introspectSignal.name !== name); return this; } /** * Calls a defined method on this interface. * Executes the method implementation with the provided arguments after validating the input signature * to ensure compatibility with the defined method signature. * * @param name - The name of the method to call. * @param payloadSignature - The signature of the input arguments provided (e.g., 'si' for string and integer). * @param args - The arguments to pass to the method. * @returns A Promise resolving to an object containing the method's output signature (if defined) and result. * @throws {DBusError} If the method is not found or if the input signature does not match the expected signature. */ async callMethod(name, payloadSignature, ...args) { if (!this.#definedMethods[name]) throw (0, CreateDBusError_1.CreateDBusError)('org.freedesktop.DBus.Error.UnknownMethod', `Method ${name} not found`); const methodInfo = this.#definedMethods[name]; if (!Signature_1.Signature.areSignaturesCompatible(methodInfo.inputSignature, payloadSignature)) throw (0, CreateDBusError_1.CreateDBusError)('org.freedesktop.DBus.Error.InvalidArgs', `The input parameter signature '${payloadSignature}' does not match the expected method signature '${methodInfo.inputSignature}'.`); const result = await methodInfo.method(...args); return { signature: methodInfo.outputSignature ? methodInfo.outputSignature : undefined, result: result }; } /** * Sets the value of a defined property on this interface. * Validates the value against the property's signature by encoding and decoding it to ensure type compatibility * before invoking the setter function to update the property. * * @param name - The name of the property to set. * @param value - The value to set for the property. * @returns void * @throws {DBusError} If the property is not found, the value signature does not match the expected type, * or the property is read-only (no setter defined). */ setProperty(name, value) { if (!this.#definedProperties[name]) throw (0, CreateDBusError_1.CreateDBusError)('org.freedesktop.DBus.Error.UnknownProperty', `Property ${name} not found`); try { // Encode and decode the value to ensure it matches the property's signature const encoder = new DBusBufferEncoder_1.DBusBufferEncoder(); const decoder = new DBusBufferDecoder_1.DBusBufferDecoder(encoder.endianness, encoder.encode(this.#definedProperties[name].signature, value)); [value] = decoder.decode(this.#definedProperties[name].signature, false); } catch (e) { throw (0, CreateDBusError_1.CreateDBusError)('org.freedesktop.DBus.Error.InvalidArgs', `The property signature '${this.#definedProperties[name].signature}' does not match its value.`); } if (this.#definedProperties[name].setter) return this.#definedProperties[name].setter(value); throw (0, CreateDBusError_1.CreateDBusError)('org.freedesktop.DBus.Error.PropertyReadOnly', `Property ${name} is read only`); } /** * Gets the value of a defined property on this interface. * Retrieves the current value by invoking the getter function if available, returning it as a raw value. * * @param name - The name of the property to get. * @returns The property value as returned by the getter function. * @throws {DBusError} If the property is not found or is write-only (no getter defined). */ getProperty(name) { if (!this.#definedProperties[name]) throw (0, CreateDBusError_1.CreateDBusError)('org.freedesktop.DBus.Error.UnknownProperty', `Property ${name} not found`); if (this.#definedProperties[name].getter) return this.#definedProperties[name].getter(); throw (0, CreateDBusError_1.CreateDBusError)('org.freedesktop.DBus.Error.PropertyWriteOnly', `Property ${name} is write only`); } /** * Gets the value of a defined property as a DBusSignedValue on this interface. * Retrieves the property value using getProperty and parses it into a DBusSignedValue with the correct signature, * ensuring it is suitable for DBus operations like method replies or signal data. * * @param name - The name of the property to get. * @returns A DBusSignedValue instance representing the property value with its associated signature. * @throws {DBusError} If the property is not found or is write-only (no getter defined). */ getPropertySignedValue(name) { const propertyValue = this.getProperty(name); return DBusSignedValue_1.DBusSignedValue.parse(this.#definedProperties[name].signature, propertyValue)[0]; } /** * Gets all managed properties as a record of DBusSignedValue objects. * Iterates through all defined property names on this interface and retrieves their current values * as DBusSignedValue instances, providing a comprehensive view of the interface's properties. * * @returns A record mapping property names to their corresponding DBusSignedValue instances. */ getManagedProperties() { const record = {}; for (const propertyName of this.propertyNames()) { record[propertyName] = this.getPropertySignedValue(propertyName); } return record; } /** * Lists the names of all defined methods on this interface. * Provides a convenient way to inspect the available methods for introspection or debugging purposes. * * @returns An array of method names as strings. */ methodNames() { return Object.keys(this.#definedMethods); } /** * Lists the names of all defined properties on this interface. * Provides a convenient way to inspect the available properties for introspection or debugging purposes. * * @returns An array of property names as strings. */ propertyNames() { return Object.keys(this.#definedProperties); } /** * Lists the names of all defined signals on this interface. * Provides a convenient way to inspect the available signals for introspection or debugging purposes. * * @returns An array of signal names as strings. */ signalNames() { return Object.keys(this.#definedSignals); } } exports.LocalInterface = LocalInterface;