// src/backends/bluez/bluez-dbus-next-boundary.ts import * as dbus from 'dbus-next' import { BLUEZ_ADAPTER_INTERFACE, BLUEZ_DEVICE_INTERFACE, BLUEZ_GATT_CHARACTERISTIC_INTERFACE, BLUEZ_GATT_DESCRIPTOR_INTERFACE, BLUEZ_OBJECT_MANAGER_INTERFACE, BLUEZ_SERVICE, DBUS_PROPERTIES_INTERFACE, BluezDbusMethodError, type BluezBusKind, type BluezDbusBoundary, type BluezDbusBoundaryFactory, type BluezInterfacesAdded, type BluezInterfacesRemoved, type BluezListener, type BluezManagedInterface, type BluezManagedObject, type BluezMethodBoundary, type BluezMethodOptions, type BluezObjectManagerBoundary, type BluezProperties, type BluezPropertiesChanged, type BluezVariant } from './bluez-dbus-contract' interface ObjectManagerProxy extends dbus.ClientInterface { GetManagedObjects(): Promise } interface DbusDaemonProxy extends dbus.ClientInterface { AddMatch(rule: string): Promise RemoveMatch(rule: string): Promise } interface AdapterProxy extends dbus.ClientInterface { SetDiscoveryFilter(filter: Readonly>): Promise StartDiscovery(): Promise StopDiscovery(): Promise /** Only exported by experimental bluetoothd builds; guarded before use. */ ConnectDevice(properties: Readonly>): Promise RemoveDevice(device: string): Promise } interface DeviceProxy extends dbus.ClientInterface { Connect(): Promise Disconnect(): Promise Pair(): Promise CancelPairing(): Promise } interface AgentManagerProxy extends dbus.ClientInterface { RegisterAgent(path: string, capability: string): Promise } interface CharacteristicProxy extends dbus.ClientInterface { ReadValue(options: Readonly>): Promise WriteValue(value: Uint8Array, options: Readonly>): Promise StartNotify(): Promise StopNotify(): Promise } interface DescriptorProxy extends dbus.ClientInterface { ReadValue(options: Readonly>): Promise WriteValue(value: Uint8Array, options: Readonly>): Promise } interface RawVariant { readonly signature: string readonly value: | string | boolean | number | bigint | Uint8Array | readonly string[] | Readonly> } type RawProperties = Readonly> type RawInterfaces = Readonly> type RawManagedObjects = Readonly> const supportedVariantSignatures = new Set([ 's', 'o', 'b', 'y', 'n', 'q', 'i', 'u', 'x', 't', 'd', 'ay', 'as', 'ao', 'a{sv}', 'a{qv}' ]) const dbusService = 'org.freedesktop.DBus' const dbusPath = '/org/freedesktop/DBus' const BLUEZ_AGENT_MANAGER_INTERFACE = 'org.bluez.AgentManager1' const BLUEZ_AGENT_INTERFACE = 'org.bluez.Agent1' const UBM_AGENT_PATH = '/org/bluez/unifiedble/agent' /** No display, no keyboard: the system uses the just-works association model. */ const UBM_AGENT_CAPABILITY = 'NoInputNoOutput' /** * Builds the just-works pairing agent class lazily, so importing this module * does not require dbus.interface (test mocks may omit it). Cached after first * use. The agent accepts just-works pairing; input-requiring methods are * rejected because NoInputNoOutput cannot satisfy them. */ let cachedAgentClass: (new (name: string) => dbus.interface.Interface) | null = null function pairingAgentClass(): new (name: string) => dbus.interface.Interface { if (cachedAgentClass !== null) return cachedAgentClass class UbmJustWorksAgent extends dbus.interface.Interface { Release(): void { // BlueZ released the agent; nothing of ours to tear down here. } RequestConfirmation(_device: string, _passkey: number): void { // Just-works association: returning without error confirms the pairing. } AuthorizeService(_device: string, _uuid: string): void { // Authorize any service on a device this client chose to pair with. } RequestAuthorization(_device: string): void { // Just-works incoming authorization: accept by returning without error. } Cancel(): void { // BlueZ aborted the ceremony; no partial state of ours to unwind. } RequestPinCode(_device: string): string { throw new dbus.DBusError('org.bluez.Error.Rejected', 'NoInputNoOutput cannot supply a PIN') } RequestPasskey(_device: string): number { throw new dbus.DBusError('org.bluez.Error.Rejected', 'NoInputNoOutput cannot supply a passkey') } DisplayPinCode(_device: string, _pincode: string): void { // NoInputNoOutput has no display; nothing to show. } DisplayPasskey(_device: string, _passkey: number, _entered: number): void { // NoInputNoOutput has no display; nothing to show. } } UbmJustWorksAgent.configureMembers({ methods: { Release: { inSignature: '', outSignature: '' }, RequestConfirmation: { inSignature: 'ou', outSignature: '' }, AuthorizeService: { inSignature: 'os', outSignature: '' }, RequestAuthorization: { inSignature: 'o', outSignature: '' }, Cancel: { inSignature: '', outSignature: '' }, RequestPinCode: { inSignature: 'o', outSignature: 's' }, RequestPasskey: { inSignature: 'o', outSignature: 'u' }, DisplayPinCode: { inSignature: 'os', outSignature: '' }, DisplayPasskey: { inSignature: 'ouq', outSignature: '' } } }) cachedAgentClass = UbmJustWorksAgent return cachedAgentClass } const bluezMatchRules = Object.freeze([ "type='signal',sender='org.bluez',interface='org.freedesktop.DBus.ObjectManager',path='/'", "type='signal',sender='org.bluez',interface='org.freedesktop.DBus.Properties',path_namespace='/org/bluez'", "type='signal',sender='org.freedesktop.DBus',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='org.bluez'" ]) /** Opens an explicit system or session bus without performing host detection at import time. */ export class DbusNextBluezBoundaryFactory implements BluezDbusBoundaryFactory { async open(busKind: BluezBusKind): Promise { const bus = busKind === 'system' ? dbus.systemBus() : dbus.sessionBus() let daemon: DbusDaemonProxy | null = null const installedRules: string[] = [] try { const daemonProxy = await bus.getProxyObject(dbusService, dbusPath) daemon = daemonProxy.getInterface(dbusService) for (const rule of bluezMatchRules) { await daemon.AddMatch(rule) installedRules.push(rule) } const proxy = await bus.getProxyObject(BLUEZ_SERVICE, '/') const manager = proxy.getInterface(BLUEZ_OBJECT_MANAGER_INTERFACE) return new DbusNextBluezBoundary(busKind, bus, daemon, manager, installedRules) } catch (error) { if (daemon !== null) { for (const rule of installedRules.reverse()) { try { await daemon.RemoveMatch(rule) } catch (cleanupError) { console.error('[DbusNextBluezBoundaryFactory.open] Failed to remove D-Bus match rule:', cleanupError) } } } bus.disconnect() throw normalizeDbusError(error) } } } class DbusNextBluezBoundary implements BluezDbusBoundary { readonly objectManager: BluezObjectManagerBoundary readonly methods: BluezMethodBoundary private readonly added = new Set<(event: BluezInterfacesAdded) => void>() private readonly removed = new Set<(event: BluezInterfacesRemoved) => void>() private readonly changed = new Set<(event: BluezPropertiesChanged) => void>() private readonly resets = new Set<(reason: string) => void>() private ordinal = 1 private pairingAgent: dbus.interface.Interface | null = null private pairingAgentPromise: Promise | null = null private closed = false private disconnected = false private resetEmitted = false private readonly remainingMatchRules: Set constructor( readonly busKind: BluezBusKind, private readonly bus: dbus.MessageBus, private readonly daemon: DbusDaemonProxy, manager: ObjectManagerProxy, matchRules: readonly string[] ) { this.remainingMatchRules = new Set(matchRules) this.objectManager = { getManagedObjects: async () => decodeManagedObjects(await manager.GetManagedObjects()), onInterfacesAdded: listener => listenerRegistration(this.added, listener), onInterfacesRemoved: listener => listenerRegistration(this.removed, listener), onPropertiesChanged: listener => listenerRegistration(this.changed, listener) } this.methods = { callVoid: async (path, interfaceName, method, argumentsValue) => { await this.callVoid(path, interfaceName, method, argumentsValue) }, callBytes: async (path, interfaceName, method, options) => this.callBytes(path, interfaceName, method, options) } this.bus.on('message', this.handleMessage) this.bus.on('error', this.handleBusError) } /** * Registers a just-works pairing agent so security.pair() can complete. * Registered on UBM's own bus, so BlueZ uses it for pairings this client * initiates (Device1.Pair) without becoming the system default agent. * * Idempotent and concurrency-safe: a single in-flight promise is shared, and * the agent object is exported exactly once. Cleared on a daemon reset so it * re-registers after bluetoothd restarts. */ ensurePairingAgent(): Promise { // Nothing to register on a torn-down bus; pair() gates on assertUsable, so // this is defense-in-depth against a post-close caller exporting on a dead // connection. if (this.closed || this.disconnected) return Promise.resolve() if (this.pairingAgentPromise !== null) return this.pairingAgentPromise const attempt = this.registerPairingAgent().catch(error => { // Allow a later retry rather than wedging on a transient failure, but only // clear our own memoization: a reset may already have installed a // successor promise, and nulling that would drop a live registration. if (this.pairingAgentPromise === attempt) this.pairingAgentPromise = null throw error }) this.pairingAgentPromise = attempt return attempt } private async registerPairingAgent(): Promise { try { if (this.pairingAgent === null) { const AgentClass = pairingAgentClass() const agent = new AgentClass(BLUEZ_AGENT_INTERFACE) this.bus.export(UBM_AGENT_PATH, agent) this.pairingAgent = agent } const exported = this.pairingAgent const manager = await this.bus.getProxyObject(BLUEZ_SERVICE, '/org/bluez') // A close() or a daemon reset during the proxy round-trip unexports the // agent (forgetPairingAgent nulls or replaces pairingAgent); do not // register a path that no longer exists on our bus. The next ensure // re-exports it. if (this.closed || this.disconnected || this.pairingAgent !== exported) return const agentManager = manager.getInterface(BLUEZ_AGENT_MANAGER_INTERFACE) await agentManager.RegisterAgent(UBM_AGENT_PATH, UBM_AGENT_CAPABILITY) } catch (error) { // A prior registration of this exact path is benign. if (error instanceof dbus.DBusError && error.type === 'org.bluez.Error.AlreadyExists') { return } // Surface every other D-Bus failure - export, getProxyObject, // getInterface, or RegisterAgent - with the same typed code the rest of // the boundary preserves, rather than letting a raw dbus-next error // escape to the public layer on a daemon-failure path. throw normalizeDbusError(error) } } /** Drops the agent registration so a later pair re-registers (e.g. after reset). */ private forgetPairingAgent(): void { this.pairingAgentPromise = null if (this.pairingAgent !== null) { try { this.bus.unexport(UBM_AGENT_PATH, this.pairingAgent) } catch { // Bus may already be gone; nothing to release. } this.pairingAgent = null } } onReset(listener: (reason: string) => void): BluezListener { return listenerRegistration(this.resets, listener) } async close(): Promise { if (this.disconnected) { return } if (!this.closed) { this.closed = true this.forgetPairingAgent() this.bus.removeListener('message', this.handleMessage) this.bus.removeListener('error', this.handleBusError) this.added.clear() this.removed.clear() this.changed.clear() this.resets.clear() } const failures: Error[] = [] for (const rule of [...this.remainingMatchRules].reverse()) { try { await this.daemon.RemoveMatch(rule) this.remainingMatchRules.delete(rule) } catch (error) { const normalized = normalizeDbusError(error) failures.push(normalized) console.error('[DbusNextBluezBoundary.close] Failed to remove D-Bus match rule:', normalized) } } if (failures.length > 0) { throw new AggregateError(failures, 'Failed to remove one or more BlueZ D-Bus match rules') } this.bus.disconnect() this.disconnected = true } private readonly handleBusError = (error: Error): void => { if (this.closed) { return } console.error('[DbusNextBluezBoundary.handleBusError] D-Bus connection failed:', error) this.emitReset('D-Bus connection failed') } private readonly handleMessage = (message: dbus.Message): void => { if (this.closed || message.type !== dbus.MessageType.SIGNAL || message.body.length === 0) { return } try { if (message.interface === BLUEZ_OBJECT_MANAGER_INTERFACE && message.member === 'InterfacesAdded') { const path = message.body[0] const interfaces = message.body[1] if (typeof path !== 'string') { throw new Error('InterfacesAdded path is not a string') } const event = Object.freeze({ ordinal: this.nextOrdinal(), path, interfaces: decodeInterfaces(interfaces) }) for (const listener of [...this.added]) { listener(event) } return } if (message.interface === BLUEZ_OBJECT_MANAGER_INTERFACE && message.member === 'InterfacesRemoved') { const path = message.body[0] const interfaces = message.body[1] if ( typeof path !== 'string' || !Array.isArray(interfaces) || !interfaces.every(value => typeof value === 'string') ) { throw new Error('InterfacesRemoved payload is malformed') } const event = Object.freeze({ ordinal: this.nextOrdinal(), path, interfaces: Object.freeze([...interfaces]) }) for (const listener of [...this.removed]) { listener(event) } return } if (message.interface === DBUS_PROPERTIES_INTERFACE && message.member === 'PropertiesChanged') { const interfaceName = message.body[0] const changed = message.body[1] const invalidated = message.body[2] if ( typeof interfaceName !== 'string' || !Array.isArray(invalidated) || !invalidated.every(value => typeof value === 'string') ) { throw new Error('PropertiesChanged payload is malformed') } const event = Object.freeze({ ordinal: this.nextOrdinal(), path: message.path, interfaceName, changed: decodeProperties(changed), invalidated: Object.freeze([...invalidated]) }) for (const listener of [...this.changed]) { listener(event) } return } if (message.interface === dbusService && message.member === 'NameOwnerChanged') { const name = message.body[0] const oldOwner = message.body[1] const newOwner = message.body[2] if (typeof name !== 'string' || typeof oldOwner !== 'string' || typeof newOwner !== 'string') { throw new Error('NameOwnerChanged payload is malformed') } if (name === BLUEZ_SERVICE && oldOwner.length > 0 && newOwner.length === 0) { this.emitReset('BlueZ D-Bus service owner disappeared') } else if (name === BLUEZ_SERVICE && oldOwner.length === 0 && newOwner.length > 0) { this.resetEmitted = false } } } catch (error) { console.error('[DbusNextBluezBoundary.handleMessage] BlueZ signal decoding failed:', error) } } private emitReset(reason: string): void { if (this.resetEmitted) { return } this.resetEmitted = true // bluetoothd dropped every agent registration; force re-registration on the // next pair rather than trusting a stale flag. this.forgetPairingAgent() for (const listener of [...this.resets]) { listener(reason) } } private nextOrdinal(): number { const ordinal = this.ordinal this.ordinal += 1 return ordinal } private async callVoid( path: string, interfaceName: string, method: string, argumentsValue: readonly BluezVariant[] ): Promise { try { const proxy = await this.bus.getProxyObject(BLUEZ_SERVICE, path) if (interfaceName === BLUEZ_ADAPTER_INTERFACE) { const adapter = proxy.getInterface(interfaceName) if (method === 'SetDiscoveryFilter' && argumentsValue.length === 1) { await adapter.SetDiscoveryFilter(variantDictionary(argumentsValue[0])) return } if (method === 'StartDiscovery' && argumentsValue.length === 0) { await adapter.StartDiscovery() return } if (method === 'StopDiscovery' && argumentsValue.length === 0) { await adapter.StopDiscovery() return } if (method === 'RemoveDevice' && argumentsValue.length === 1) { await adapter.RemoveDevice(variantObjectPath(argumentsValue[0])) return } if (method === 'ConnectDevice' && argumentsValue.length === 1) { // ConnectDevice is only exported by experimental bluetoothd builds; a daemon // without it must surface the same UnknownMethod error a raw call would. if (typeof adapter.ConnectDevice !== 'function') { throw new BluezDbusMethodError({ name: 'org.freedesktop.DBus.Error.UnknownMethod', message: 'BlueZ adapter does not export ConnectDevice', safeDetails: Object.freeze({}) }) } await adapter.ConnectDevice(variantDictionary(argumentsValue[0])) return } } if (interfaceName === BLUEZ_DEVICE_INTERFACE && argumentsValue.length === 0) { const device = proxy.getInterface(interfaceName) if (method === 'Connect') { await device.Connect() return } if (method === 'Disconnect') { await device.Disconnect() return } if (method === 'Pair') { await device.Pair() return } if (method === 'CancelPairing') { await device.CancelPairing() return } } if (interfaceName === BLUEZ_GATT_CHARACTERISTIC_INTERFACE) { const characteristic = proxy.getInterface(interfaceName) if (method === 'WriteValue' && argumentsValue.length === 2) { await characteristic.WriteValue(variantBytes(argumentsValue[0]), variantDictionary(argumentsValue[1])) return } if (method === 'StartNotify' && argumentsValue.length === 0) { await characteristic.StartNotify() return } if (method === 'StopNotify' && argumentsValue.length === 0) { await characteristic.StopNotify() return } } if (interfaceName === BLUEZ_GATT_DESCRIPTOR_INTERFACE && method === 'WriteValue' && argumentsValue.length === 2) { const descriptor = proxy.getInterface(interfaceName) await descriptor.WriteValue(variantBytes(argumentsValue[0]), variantDictionary(argumentsValue[1])) return } throw new Error(`Unsupported BlueZ method ${interfaceName}.${method}`) } catch (error) { throw normalizeDbusError(error) } } private async callBytes( path: string, interfaceName: string, method: string, options: BluezMethodOptions ): Promise { try { const proxy = await this.bus.getProxyObject(BLUEZ_SERVICE, path) const encoded = encodeMethodOptions(options) if (interfaceName === BLUEZ_GATT_CHARACTERISTIC_INTERFACE && method === 'ReadValue') { const value = await proxy.getInterface(interfaceName).ReadValue(encoded) return new Uint8Array(value) } if (interfaceName === BLUEZ_GATT_DESCRIPTOR_INTERFACE && method === 'ReadValue') { const value = await proxy.getInterface(interfaceName).ReadValue(encoded) return new Uint8Array(value) } throw new Error(`Unsupported BlueZ byte method ${interfaceName}.${method}`) } catch (error) { throw normalizeDbusError(error) } } } function listenerRegistration( listeners: Set<(event: Event) => void>, listener: (event: Event) => void ): BluezListener { listeners.add(listener) let removed = false return { remove: () => { if (removed) { return } removed = true listeners.delete(listener) } } } function decodeManagedObjects(objects: RawManagedObjects): readonly BluezManagedObject[] { return Object.freeze( Object.entries(objects) .sort(([left], [right]) => left.localeCompare(right)) .map(([path, interfaces]) => Object.freeze({ path, interfaces: decodeInterfaces(interfaces) })) ) } function decodeInterfaces(interfaces: RawInterfaces): readonly BluezManagedInterface[] { return Object.freeze( Object.entries(interfaces) .sort(([left], [right]) => left.localeCompare(right)) .map(([name, properties]) => Object.freeze({ name, properties: decodeProperties(properties) })) ) } function decodeProperties(properties: RawProperties): BluezProperties { const decoded: Record = {} for (const [name, value] of Object.entries(properties)) { decoded[name] = decodeVariant(value) } return Object.freeze(decoded) } function decodeVariant(variant: RawVariant): BluezVariant { if (!supportedVariantSignatures.has(variant.signature)) { throw new Error(`Unsupported D-Bus variant signature ${variant.signature}`) } if ((variant.signature === 's' || variant.signature === 'o') && typeof variant.value === 'string') { return { signature: variant.signature, value: variant.value } } if (variant.signature === 'b' && typeof variant.value === 'boolean') { return { signature: 'b', value: variant.value } } if ( (variant.signature === 'y' || variant.signature === 'n' || variant.signature === 'q' || variant.signature === 'i' || variant.signature === 'u' || variant.signature === 'x' || variant.signature === 't' || variant.signature === 'd') && (typeof variant.value === 'number' || typeof variant.value === 'bigint') ) { return { signature: variant.signature, value: Number(variant.value) } } if (variant.signature === 'ay' && variant.value instanceof Uint8Array) { return { signature: 'ay', value: new Uint8Array(variant.value) } } if ( (variant.signature === 'as' || variant.signature === 'ao') && Array.isArray(variant.value) && variant.value.every(value => typeof value === 'string') ) { return { signature: variant.signature, value: Object.freeze([...variant.value]) } } if ((variant.signature === 'a{sv}' || variant.signature === 'a{qv}') && isRawProperties(variant.value)) { // a{qv} dictionaries (e.g. Device1.ManufacturerData) surface with their keys already // stringified by the transport, so they are re-tagged as the string-keyed shape. return { signature: 'a{sv}', value: decodeProperties(variant.value) } } throw new Error(`Malformed D-Bus variant ${variant.signature}`) } function encodeMethodOptions(options: BluezMethodOptions): Readonly> { const encoded: Record = {} for (const [name, value] of Object.entries(options)) { encoded[name] = new dbus.Variant(typeof value === 'string' ? 's' : typeof value === 'boolean' ? 'b' : 'u', value) } return Object.freeze(encoded) } function variantDictionary(variant: BluezVariant | undefined): Readonly> { if (variant?.signature !== 'a{sv}') { throw new Error('Expected a{sv} D-Bus variant') } const encoded: Record = {} for (const [name, value] of Object.entries(variant.value)) { encoded[name] = encodeVariant(value) } return Object.freeze(encoded) } function variantBytes(variant: BluezVariant | undefined): Uint8Array { if (variant?.signature !== 'ay') { throw new Error('Expected ay D-Bus variant') } return new Uint8Array(variant.value) } function variantObjectPath(variant: BluezVariant | undefined): string { if (variant?.signature !== 'o' || typeof variant.value !== 'string') { throw new Error('Expected o (object path) D-Bus variant') } return variant.value } function encodeVariant(variant: BluezVariant): dbus.Variant { if (variant.signature === 'ay') { return new dbus.Variant('ay', new Uint8Array(variant.value)) } if (variant.signature === 'as' || variant.signature === 'ao') { return new dbus.Variant(variant.signature, [...variant.value]) } if (variant.signature === 'a{sv}') { return new dbus.Variant('a{sv}', variantDictionary(variant)) } return new dbus.Variant(variant.signature, variant.value) } function isRawProperties(value: RawVariant['value']): value is RawProperties { if (value instanceof Uint8Array || Array.isArray(value) || typeof value !== 'object') { return false } return Object.values(value).every( candidate => typeof candidate === 'object' && typeof candidate.signature === 'string' && supportedVariantSignatures.has(candidate.signature) ) } function normalizeDbusError(error: unknown): BluezDbusMethodError { if (error instanceof BluezDbusMethodError) { return error } if (!(error instanceof Error)) { return new BluezDbusMethodError({ name: 'org.bluez.Error.Failed', message: 'D-Bus rejected with a non-Error value', safeDetails: Object.freeze({}) }) } const typeDescriptor = Object.getOwnPropertyDescriptor(error, 'type') const safeName = typeof typeDescriptor?.value === 'string' ? typeDescriptor.value : error.name return new BluezDbusMethodError({ name: safeName.length === 0 ? 'org.bluez.Error.Failed' : safeName, message: error.message, safeDetails: Object.freeze({}) }) }