// src/electron/main-router.ts import { BackendContractError, contractError, type CleanupFailure, type CleanupRecord } from '../backend-contract/errors' import { ElectronMainArbiterContext, type IpcEnvelope, type RendererIdentity, type RendererLeaseIdentity, type TrustedIpcSender } from '../backend-contract/electron' import type { CharacteristicPath, CharacteristicProperties, DescriptorPath, GattAccessRequirements, GattDatabaseChangedEvent, GattDescriptorProperties } from '../backend-contract/gatt' import type { HostNeutralBackendIdentity } from '../backend-contract/identity' import { byteLimit, capacity, deadline, negotiateVersion, negotiateCoreVersions, opaqueId, ownBytes, version, versionRange, type IpcCompatibilityOffer, type IpcVersionAxes, type OwnedBytes, type SerializableRecord, type SerializableValue } from '../backend-contract/primitives' import type { SubscriptionOptions } from '../backend-contract/operations' import { snapshotCapabilityDescriptors } from '../backend-contract/capabilities' import { snapshotSerializableRecord } from '../backend-contract/serializable' import { snapshotScanPlan } from '../backend-contract/scan-planning' import type { ScanPlan } from '../backend-contract/scan-planning' import { decodeIpcScanQuery, encodeIpcScanPlan } from '../ipc/scan-planning' import { BleManager, Connection, DiscoveredGattDatabase } from '../manager/ble-manager' import type { ElectronBleIpcEvent, ElectronBleIpcRequest, ElectronBleIpcSuccessResponse, ElectronRendererBootstrap } from './protocol' import { ElectronRendererStreamRegistry, type ElectronEventDelivery, type ManagedScan, type ManagedSubscription } from './renderer-stream-registry' import { ElectronConnectionEventStreamRegistry, type ManagedConnectionEventSubscription } from './connection-event-stream-registry' import { isElectronConnectionEventsStreamHandle, type ElectronConnectionEventsSubscribeResponseV2 } from './protocol' import { electronRequestByteLength } from './ipc-message-sizing' export type { ElectronEventDelivery } from './renderer-stream-registry' type MainManager = BleManager> type MainConnection = Connection> type MainDatabase = DiscoveredGattDatabase> type MainCharacteristicPath = CharacteristicPath type MainDescriptorPath = DescriptorPath type MainCharacteristicSnapshot = Awaited>['characteristics'][number] const DEFAULT_DELIVERY: SubscriptionOptions['delivery'] = Object.freeze({ itemCapacity: capacity(128), byteCapacity: capacity(256 * 1024), reservedControlCapacity: capacity(1), overflowPolicy: 'drop-oldest' }) /** * Retention window for pre-cancelled and settled operation correlations. * * A safety bound on main-process memory, not host policy. The router must * remember an operation's terminal fate long enough to answer a late or * duplicated renderer message idempotently, and must forget it eventually or an * unbounded map grows for the process lifetime. Thirty seconds comfortably * exceeds any in-flight IPC round-trip while keeping retention bounded; renderer * code cannot influence it, which is the point -- it is a quota the trusted side * imposes on untrusted peers. */ const CANCELLATION_CORRELATION_TTL_MILLISECONDS = 30_000 export interface ElectronMainBleRouterOptions { readonly manager: MainManager readonly maximumMessageBytes: number readonly maximumOutstandingOperations: number readonly maximumRetainedBytes: number readonly publish: (rendererClientId: string, event: ElectronBleIpcEvent) => Promise /** Injected for deterministic expiry of lease-scoped cancellation correlation records. */ readonly cancellationClock?: () => number } interface RendererResources { readonly rendererLease: RendererLeaseIdentity readonly scans: Map readonly connections: Map readonly connectionEventSubscriptions: Map readonly databases: Map readonly subscriptions: Map readonly operations: Map readonly preCancelledOperations: Map readonly settledOperations: Map lifecycle: 'active' | 'releasing' releaseResult: Promise | null } interface ManagedDatabase { readonly connectionHandle: string readonly database: MainDatabase readonly characteristics: Map readonly descriptors: Map } interface ManagedOperation { readonly controller: AbortController readonly settled: Promise complete(): void } interface RendererResourceSnapshot { readonly scans: ReadonlySet readonly connections: ReadonlySet readonly connectionEventSubscriptions: ReadonlySet readonly databases: ReadonlySet readonly subscriptions: ReadonlySet } /** * Canonical Electron-main router for the v4 public manager. It owns all live * handles; renderer messages contain only opaque IDs and copied byte values. */ export class ElectronMainBleRouter { private readonly manager: MainManager private publish: ElectronMainBleRouterOptions['publish'] private readonly maximumMessageBytes: number private readonly maximumOutstandingOperations: number private readonly cancellationClock: () => number private readonly resources = new Map() private readonly arbiter: ElectronMainArbiterContext private readonly streams: ElectronRendererStreamRegistry private readonly connectionEvents: ElectronConnectionEventStreamRegistry private nextHandle = 1 private nextEvent = 1 constructor(options: ElectronMainBleRouterOptions) { this.manager = options.manager this.publish = options.publish this.maximumMessageBytes = options.maximumMessageBytes this.maximumOutstandingOperations = options.maximumOutstandingOperations this.cancellationClock = options.cancellationClock ?? (() => Date.now()) this.streams = new ElectronRendererStreamRegistry({ maximumMessageBytes: this.maximumMessageBytes, now: () => typeof this.manager.monotonicNow === 'function' ? this.manager.monotonicNow() : (globalThis.performance?.now() ?? Date.now()), publish: (rendererLeaseId, event) => this.publish(rendererLeaseId, event), createEvent: (rendererLease, streamId, item) => this.event(rendererLease, streamId, item) }) this.connectionEvents = new ElectronConnectionEventStreamRegistry({ maximumMessageBytes: this.maximumMessageBytes, publish: (rendererLeaseId, event) => this.publish(rendererLeaseId, event), createEvent: (rendererLease, streamId, item) => this.event(rendererLease, streamId, item) }) const attachment = this.manager.attachedBackend.attachment.attachment const versions = createElectronHostIpcVersionAxes(this.manager.identity.versions) this.arbiter = new ElectronMainArbiterContext( { attachment, versions, quota: { maximumMessageBytes: byteLimit(options.maximumMessageBytes), maximumOutstandingOperations: capacity(options.maximumOutstandingOperations), maximumRetainedBytes: byteLimit(options.maximumRetainedBytes) } }, { route: envelope => this.route(envelope), release: (_identity, lease) => this.releaseResources(lease.leaseId) } ) } async dispatch( sender: TrustedIpcSender, request: ElectronBleIpcRequest ): Promise> { if (request.kind === 'bootstrap') { const renderer = rendererIdentity(sender) const versions = createElectronIpcVersionAxes(this.manager.identity.versions, request.offer) const rendererLease = this.arbiter.registerRenderer(renderer, versions, sender.securityPermissions) this.resourcesFor(rendererLease) return { kind: 'bootstrap', bootstrap: this.bootstrap(renderer, rendererLease, versions) } } if (request.kind === 'route') { const payload = await this.arbiter.route(sender, request.envelope) return { kind: 'route', payload } } if (request.kind === 'release') { const cleanup = await this.arbiter.releaseRenderer(sender, request.rendererLease) return { kind: 'release', cleanup } } throw contractError('protocol.violation', 'ipc', 'electron-main-router.event-ack-binding-required') } /** Enforces the configured byte limit before any request reaches routing or acknowledgement state. */ validateRequest( request: ElectronBleIpcRequest ): void { let byteLength: number try { byteLength = electronRequestByteLength(request) } catch (error) { if (error instanceof BackendContractError) { throw error } throw contractError('protocol.malformed', 'ipc', 'electron-main-router.request-shape') } if (byteLength > this.maximumMessageBytes) { throw contractError('bytes.too-large', 'ipc', 'electron-main-router.request-size') } } /** Releases the authenticated renderer after a host-owned WebContents lifetime event. */ releaseRenderer( sender: TrustedIpcSender, rendererLease: RendererLeaseIdentity ): Promise { return this.arbiter.releaseRenderer(sender, rendererLease) } /** * Stops one stream after its authenticated renderer can no longer accept * events. The owning binding calls this only for its own renderer identity. */ async terminateStream( rendererLease: RendererLeaseIdentity, streamId: string, reason: 'renderer-backpressure' | 'renderer-unavailable' ): Promise { const rendererLeaseId = String(rendererLease.leaseId) const resources = this.resources.get(rendererLeaseId) if (resources === undefined) { return } if (await this.connectionEvents.terminate(resources, rendererLease, streamId, reason)) { return } await this.streams.terminate(resources, rendererLease, streamId, reason) } async destroy(): Promise { const rendererFailures: CleanupFailure[] = [] for (const clientId of [...this.resources.keys()]) { try { const cleanup = await this.releaseResources(clientId) rendererFailures.push(...cleanup.failures) } catch (error) { console.error('[ElectronMainBleRouter] Renderer cleanup rejected during router destroy:', { clientId, error }) rendererFailures.push({ resourceKind: 'electron-renderer', error: normalizedCleanupError(error) }) } } let managerCleanup: CleanupRecord try { managerCleanup = await this.manager.destroy() } catch (error) { console.error('[ElectronMainBleRouter] Manager cleanup rejected during router destroy:', error) managerCleanup = { state: 'release-failed', failures: [{ resourceKind: 'manager', error: normalizedCleanupError(error) }] } } const failures = [...rendererFailures, ...managerCleanup.failures] return failures.length === 0 ? { state: 'released', failures: [] } : { state: 'release-failed', failures } } /** Installs the application-owned event delivery binding after IPC authentication is configured. */ setEventPublisher(publish: ElectronMainBleRouterOptions['publish']): void { this.publish = publish } private bootstrap( renderer: RendererIdentity, rendererLease: RendererLeaseIdentity, versions: IpcVersionAxes ): ElectronRendererBootstrap { const attachment = this.manager.attachedBackend.attachment.attachment const capabilities = this.manager.capabilities() return Object.freeze({ attachment, attachmentId: attachment.attachmentId, versions, capabilities: snapshotCapabilityDescriptors(capabilities, String(attachment.backendGeneration)), discovery: discoveryDescriptor(capabilities), renderer, rendererLease }) } private async route( envelope: IpcEnvelope ): Promise { const resources = this.resourcesFor(envelope.rendererLease) if (resources.lifecycle !== 'active') { throw contractError('lifecycle.invalid-state', 'ipc', 'electron-main-router.renderer-releasing') } if (envelope.command === 'operation.cancel') { return this.cancel(resources, envelope.payload) } const operationKey = String(envelope.correlation) this.pruneCancellationCorrelations(resources) if (resources.operations.has(operationKey)) { throw contractError('protocol.violation', 'ipc', 'electron-main-router.correlation-in-flight') } const controller = new AbortController() const operation = createManagedOperation(controller) const resourceSnapshot = snapshotResourceHandles(resources) resources.settledOperations.delete(operationKey) if (resources.preCancelledOperations.delete(operationKey)) { controller.abort() } resources.operations.set(operationKey, operation) try { const preAdmissionFailure = operationAdmissionFailure( controller, envelope.payload, () => this.manager.monotonicNow(), envelope.command ) if (preAdmissionFailure !== null) { throw preAdmissionFailure } let response: SerializableRecord if (envelope.command === 'scan.start') { response = await this.startScan(resources, envelope, controller) } else if (envelope.command === 'scan.stop') { response = await this.stopScan(resources, envelope.payload) } else if (envelope.command === 'connection.connect') { response = await this.connect(resources, envelope.payload, controller) } else if (envelope.command === 'adapter.state') { response = await this.adapterState(controller) } else if (envelope.command === 'connection.rssi') { response = await this.readRssi(resources, envelope.payload, controller) } else if (envelope.command === 'connection.maximum-write-length') { response = await this.maximumWriteLength(resources, envelope.payload, controller) } else if (envelope.command === 'connection.disconnect') { response = await this.disconnect(resources, envelope.payload) } else if (envelope.command === 'connection.events.subscribe') { response = this.subscribeConnectionEvents(resources, envelope.payload) } else if (envelope.command === 'connection.events.ready') { response = this.readyConnectionEvents(resources, envelope.payload) } else if (envelope.command === 'connection.events.unsubscribe') { response = await this.unsubscribeConnectionEvents(resources, envelope.payload) } else if (envelope.command === 'gatt.discover') { response = await this.discover(resources, envelope.payload, controller) } else if (envelope.command === 'gatt.database.release') { response = this.releaseDatabase(resources, envelope.payload) } else if (envelope.command === 'gatt.read') { response = await this.read(resources, envelope.payload, controller) } else if (envelope.command === 'gatt.write') { response = await this.write(resources, envelope.payload, envelope.binaryPayload, controller) } else if (envelope.command === 'gatt.descriptor.read') { response = await this.readDescriptor(resources, envelope.payload, controller) } else if (envelope.command === 'gatt.descriptor.write') { response = await this.writeDescriptor(resources, envelope.payload, envelope.binaryPayload, controller) } else if (envelope.command === 'gatt.subscribe') { response = await this.subscribe(resources, envelope, controller) } else if (envelope.command === 'gatt.unsubscribe') { response = await this.unsubscribe(resources, envelope.payload) } else { throw contractError('argument.invalid', 'ipc', 'electron-main-router.command') } if (!isDestructiveCleanupCommand(envelope.command)) { const admissionFailure = operationAdmissionFailure( controller, envelope.payload, () => this.manager.monotonicNow(), envelope.command ) if (admissionFailure !== null) { const rollback = await this.rollbackOperationAdmission(resources, resourceSnapshot, envelope) if (rollback.state === 'release-failed') { throw contractError('lifecycle.invalid-state', 'ipc', 'electron-main-router.rollback-release-required') } throw admissionFailure } } if (snapshotSerializableRecord(response).byteLength > this.maximumMessageBytes) { if (!isDestructiveCleanupCommand(envelope.command)) { const rollback = await this.rollbackOperationAdmission(resources, resourceSnapshot, envelope) if (rollback.state === 'release-failed') { throw contractError('lifecycle.invalid-state', 'ipc', 'electron-main-router.rollback-release-required') } } throw contractError('bytes.too-large', 'ipc', 'electron-main-router.response-size') } return response } finally { if (resources.operations.get(operationKey) === operation) { resources.operations.delete(operationKey) } operation.complete() this.recordSettledOperation(resources, operationKey) } } private async startScan( resources: RendererResources, envelope: IpcEnvelope, controller: AbortController ): Promise { const query = decodeIpcScanQuery(envelope.payload.query, 'electron-main-router.scan-query') if (typeof this.manager.planScan !== 'function') { throw contractError('capability.unavailable', 'scan', 'electron-main-router.scan-planner') } let plan: ScanPlan try { const planned = this.manager.planScan(query) if (planned === null) { throw contractError('capability.unavailable', 'scan', 'electron-main-router.scan-planner') } plan = snapshotScanPlan(planned) } catch (error) { if (error instanceof BackendContractError) throw error throw contractError('protocol.violation', 'scan', 'electron-main-router.scan-plan') } if (plan.queryDigest !== query.digest || plan.sourceQuery.digest !== query.digest) { throw contractError('protocol.violation', 'scan', 'electron-main-router.scan-plan-digest') } const scan = await this.manager.scan({ query, plan, filter: { serviceUuids: [], manufacturerData: [], localNamePrefix: null }, duplicatePolicy: 'all', timestampPolicy: 'receipt-monotonic', delivery: deliveryFromPayload(envelope.payload), deadline: deadlineFromPayload(envelope.payload), signal: controller.signal, sharing: { mode: 'owner', allowSharing: false } }) const handle = this.allocateHandle('scan') this.streams.registerScan(resources, envelope.rendererLease, handle, scan) return Object.freeze({ handle, backendGeneration: String(this.manager.attachedBackend.attachment.attachment.backendGeneration), plan: encodeIpcScanPlan(plan) }) } private async connect( resources: RendererResources, payload: SerializableRecord, controller: AbortController ): Promise { const peerId = opaqueId(requiredString(payload, 'peerId'), 'peer', 'electron-router') const connection = await this.manager.connect(peerId, operationOptions(payload, controller)) const handle = this.allocateHandle('connection') resources.connections.set(handle, connection) return Object.freeze({ handle, peerId: String(connection.peerId), connectionId: String(connection.connectionId), ownerLeaseId: String(resources.rendererLease.leaseId), connectionGeneration: String(connection.connectionGeneration) }) } private async adapterState(controller: AbortController): Promise { const pending = this.manager.adapterState() const aborted = new Promise((_, reject) => { const fail = (): void => { reject(contractError('operation.aborted', 'adapter', 'electron-main-router.adapter-state')) } if (controller.signal.aborted) { fail() return } controller.signal.addEventListener('abort', fail, { once: true }) }) try { const state = await Promise.race([pending, aborted]) return Object.freeze({ state: Object.freeze({ availability: state.availability, authorization: state.authorization, power: state.power, heard: null, backendGeneration: String(state.backendGeneration), updatedAt: state.updatedAt, safeReason: state.safeReason }) }) } finally { pending.then( () => undefined, () => undefined ) } } private async maximumWriteLength( resources: RendererResources, payload: SerializableRecord, controller: AbortController ): Promise { const mode = payload.mode if (mode !== 'with-response' && mode !== 'without-response') { throw contractError('argument.invalid', 'ipc', 'electron-main-router.maximum-write-length.mode') } const connection = requiredResource( resources.connections, requiredString(payload, 'connectionHandle'), 'connection' ) const result = await connection.maximumWriteLength(mode, operationOptions(payload, controller)) return Object.freeze({ bytes: result.maximumWriteLength, mode, observedAtMonotonicMs: result.observedAtMonotonicMs }) } private async readRssi( resources: RendererResources, payload: SerializableRecord, controller: AbortController ): Promise { const connection = requiredResource( resources.connections, requiredString(payload, 'connectionHandle'), 'connection' ) const result = await connection.readRssi(operationOptions(payload, controller)) return Object.freeze({ rssi: result.rssi }) } private async discover( resources: RendererResources, payload: SerializableRecord, controller: AbortController ): Promise { const connectionHandle = requiredString(payload, 'connectionHandle') const connection = requiredResource(resources.connections, connectionHandle, 'connection') const reason = optionalRediscoveryReason(payload) const retirement = await this.retireDatabasesForRediscovery(resources, connectionHandle) if (retirement.state === 'release-failed') { throw new BackendContractError( retirement.failures[0]?.error ?? contractError('platform.failure', 'cleanup', 'electron-main-router.rediscovery-retirement').normalized ) } const database = reason === null ? await connection.discover(operationOptions(payload, controller)) : await connection.rediscoverGatt(operationOptions(payload, controller), reason) const snapshot = await database.snapshot() const characteristics = new Map() const descriptors = new Map() const characteristicHandles = new Map() const serializedCharacteristics: SerializableValue[] = [] const serializedDescriptors: SerializableValue[] = [] const serializedServices: SerializableValue[] = (snapshot.services ?? []).map(service => Object.freeze({ uuid: String(service.path.serviceUuid), occurrence: String(service.path.serviceOccurrence), primary: service.primary, includedServices: service.includedServices.map(included => Object.freeze({ uuid: String(included.uuid), occurrence: String(included.occurrence) }) ) }) ) for (const characteristic of snapshot.characteristics ?? []) { const handle = this.allocateHandle('characteristic') characteristics.set(handle, characteristic.path) characteristicHandles.set(characteristicKey(characteristic.path), handle) serializedCharacteristics.push( Object.freeze({ handle, serviceUuid: String(characteristic.path.serviceUuid), serviceOccurrence: String(characteristic.path.serviceOccurrence), characteristicUuid: String(characteristic.path.characteristicUuid), characteristicOccurrence: String(characteristic.path.characteristicOccurrence), properties: characteristicProperties(characteristic), ...(characteristic.properties === undefined ? {} : { propertiesMetadata: serializeCharacteristicProperties(characteristic.properties) }), ...(characteristic.access === undefined ? {} : { access: serializeAccessRequirements(characteristic.access) }) }) ) } for (const descriptor of snapshot.descriptors ?? []) { const characteristicHandle = characteristicHandles.get(characteristicKey(descriptor.path)) if (characteristicHandle === undefined) { throw contractError('protocol.violation', 'gatt', 'electron-main-router.descriptor-parent') } const handle = this.allocateHandle('descriptor') descriptors.set(handle, descriptor.path) serializedDescriptors.push( Object.freeze({ handle, characteristicHandle, uuid: String(descriptor.path.descriptorUuid), occurrence: String(descriptor.path.descriptorOccurrence), ...(descriptor.properties === undefined ? {} : { properties: serializeDescriptorProperties(descriptor.properties) }) }) ) } const handle = this.allocateHandle('database') resources.databases.set(handle, { connectionHandle, database, characteristics, descriptors }) const response = Object.freeze({ schemaVersion: 2, handle, databaseId: String(database.path?.databaseId ?? ''), databaseGeneration: String(database.path?.databaseGeneration ?? ''), services: Object.freeze(serializedServices), characteristics: Object.freeze(serializedCharacteristics), descriptors: Object.freeze(serializedDescriptors) }) return reason === null ? response : Object.freeze({ ...response, rediscoveryReason: reason }) } private async read( resources: RendererResources, payload: SerializableRecord, controller: AbortController ): Promise { const database = this.database(resources, payload) const path = this.characteristic(database, payload) const value = await database.database.read(path, operationOptions(payload, controller)) return Object.freeze({ value: ownBytes(value, byteLimit(value.byteLength)) }) } private async write( resources: RendererResources, payload: SerializableRecord, binaryPayload: OwnedBytes | null, controller: AbortController ): Promise { if (binaryPayload === null) { throw contractError('bytes.invalid', 'ipc', 'electron-main-router.write-missing-bytes') } const database = this.database(resources, payload) const path = this.characteristic(database, payload) const mode = requiredWriteMode(payload) const receipt = await database.database.write(path, new Uint8Array(binaryPayload), { ...operationOptions(payload, controller), mode }) return serializeWriteReceipt(receipt, mode, binaryPayload.byteLength) } private async readDescriptor( resources: RendererResources, payload: SerializableRecord, controller: AbortController ): Promise { const database = this.database(resources, payload) const path = this.descriptor(database, payload) const value = await database.database.readDescriptor(path, operationOptions(payload, controller)) return Object.freeze({ value: ownBytes(value, byteLimit(value.byteLength)) }) } private async writeDescriptor( resources: RendererResources, payload: SerializableRecord, binaryPayload: OwnedBytes | null, controller: AbortController ): Promise { if (binaryPayload === null) { throw contractError('bytes.invalid', 'ipc', 'electron-main-router.descriptor-write-missing-bytes') } const database = this.database(resources, payload) const path = this.descriptor(database, payload) const receipt = await database.database.writeDescriptor(path, new Uint8Array(binaryPayload), { ...operationOptions(payload, controller), mode: 'with-response' }) return serializeWriteReceipt(receipt, 'with-response', binaryPayload.byteLength) } private async subscribe( resources: RendererResources, envelope: IpcEnvelope, controller: AbortController ): Promise { const database = this.database(resources, envelope.payload) const path = this.characteristic(database, envelope.payload) const deliveryMode = requiredDeliveryMode(envelope.payload.deliveryMode) const subscription = await database.database.subscribe(path, { ...operationOptions(envelope.payload, controller), delivery: deliveryFromPayload(envelope.payload), ...(deliveryMode === undefined ? {} : { deliveryMode }) } satisfies SubscriptionOptions) const handle = this.allocateHandle('subscription') this.streams.registerSubscription( resources, envelope.rendererLease, handle, requiredString(envelope.payload, 'databaseHandle'), subscription ) return Object.freeze({ handle }) } private async stopScan(resources: RendererResources, payload: SerializableRecord): Promise { const handle = requiredString(payload, 'scanHandle') const resource = requiredResource(resources.scans, handle, 'scan') const cleanup = await this.streams.stopScan(resources, handle, resource, true) return cleanupRecord(cleanup) } private async disconnect(resources: RendererResources, payload: SerializableRecord): Promise { const handle = requiredString(payload, 'connectionHandle') const connection = requiredResource(resources.connections, handle, 'connection') const lifecycleCleanup = await this.releaseConnectionEventSubscriptionsForConnection(resources, handle) if (lifecycleCleanup.state === 'release-failed') { return cleanupRecord(lifecycleCleanup) } const subscriptionCleanup = await this.releaseSubscriptionsForConnection(resources, handle) if (subscriptionCleanup.state === 'release-failed') { return cleanupRecord(subscriptionCleanup) } const cleanup = await this.disconnectConnection(resources, handle, connection) if (cleanup.state === 'released') { this.deleteDatabasesForConnection(resources, handle) } return cleanupRecord(cleanup) } private subscribeConnectionEvents( resources: RendererResources, payload: SerializableRecord ): ElectronConnectionEventsSubscribeResponseV2 { const connectionHandle = requiredString(payload, 'connectionHandle') const connection = requiredResource(resources.connections, connectionHandle, 'connection') const handle = requiredString(payload, 'connectionEventsHandle') if (!isElectronConnectionEventsStreamHandle(handle)) { throw contractError('argument.invalid', 'ipc', 'electron-main-router.connection-events-handle') } return this.connectionEvents.register( resources, handle, connectionHandle, connection, this.manager.attachedBackend.attachment.attachment ) } private readyConnectionEvents(resources: RendererResources, payload: SerializableRecord): SerializableRecord { const handle = requiredString(payload, 'connectionEventsHandle') this.connectionEvents.ready(resources, resources.rendererLease, handle) return Object.freeze({ state: 'ready' }) } private async unsubscribeConnectionEvents( resources: RendererResources, payload: SerializableRecord ): Promise { const handle = requiredString(payload, 'connectionEventsHandle') const resource = requiredResource(resources.connectionEventSubscriptions, handle, 'connection-events') const cleanup = await this.connectionEvents.remove(resources, handle, resource, true) return cleanupRecord(cleanup) } private async unsubscribe(resources: RendererResources, payload: SerializableRecord): Promise { const handle = requiredString(payload, 'subscriptionHandle') const resource = requiredResource(resources.subscriptions, handle, 'subscription') const cleanup = await this.streams.removeSubscription(resources, handle, resource, true) return cleanupRecord(cleanup) } private releaseDatabase(resources: RendererResources, payload: SerializableRecord): SerializableRecord { const handle = requiredString(payload, 'databaseHandle') const database = resources.databases.get(handle) if (database === undefined) return Object.freeze({ state: 'released', failures: [] }) if ( payload.connectionHandle !== undefined && database.connectionHandle !== requiredString(payload, 'connectionHandle') ) { throw contractError('protocol.violation', 'ipc', 'electron-main-router.database-release-connection') } resources.databases.delete(handle) return Object.freeze({ state: 'released', failures: [] }) } private cancel(resources: RendererResources, payload: SerializableRecord): SerializableRecord { const target = requiredString(payload, 'targetCorrelation') const now = this.pruneCancellationCorrelations(resources) const operation = resources.operations.get(target) if (operation !== undefined) { operation.controller.abort() return Object.freeze({ state: 'cancellation-requested' }) } if (resources.preCancelledOperations.has(target)) { return Object.freeze({ state: 'cancellation-pending' }) } if (resources.settledOperations.has(target)) { return Object.freeze({ state: 'already-terminal' }) } if (resources.preCancelledOperations.size >= this.maximumOutstandingOperations) { throw contractError('stream.quota', 'ipc', 'electron-main-router.pre-cancellation-capacity') } resources.preCancelledOperations.set(target, now + CANCELLATION_CORRELATION_TTL_MILLISECONDS) return Object.freeze({ state: 'cancellation-pending' }) } private database(resources: RendererResources, payload: SerializableRecord): ManagedDatabase { return requiredResource(resources.databases, requiredString(payload, 'databaseHandle'), 'database') } private characteristic(database: ManagedDatabase, payload: SerializableRecord): MainCharacteristicPath { return requiredResource(database.characteristics, requiredString(payload, 'characteristicHandle'), 'characteristic') } private descriptor(database: ManagedDatabase, payload: SerializableRecord): MainDescriptorPath { return requiredResource(database.descriptors, requiredString(payload, 'descriptorHandle'), 'descriptor') } private async releaseResources(clientId: string): Promise { const key = clientId const resources = this.resources.get(key) if (resources === undefined) { return { state: 'released', failures: [] } } if (resources.lifecycle === 'releasing') { if (resources.releaseResult === null) { throw contractError('lifecycle.invariant-violation', 'ipc', 'electron-main-router.release-accounting') } return resources.releaseResult } resources.lifecycle = 'releasing' const releaseResult = this.releaseResourcesOnce(resources).then( cleanup => { if (cleanup.state === 'released') { this.resources.delete(key) } else { resources.lifecycle = 'active' resources.releaseResult = null } return cleanup }, error => { console.error('[ElectronMainBleRouter] Renderer resource release rejected:', { clientId, error }) resources.lifecycle = 'active' resources.releaseResult = null throw error } ) resources.releaseResult = releaseResult return releaseResult } private async releaseResourcesOnce(resources: RendererResources): Promise { for (const operation of resources.operations.values()) { operation.controller.abort() } const pendingOperations = [...resources.operations.values()] for (const operation of pendingOperations) { await operation.settled } const failures: CleanupFailure[] = [] for (const [handle, connectionEvents] of resources.connectionEventSubscriptions) { const cleanup = await this.connectionEvents.remove(resources, handle, connectionEvents, true) if (cleanup.state === 'release-failed') { failures.push(...cleanup.failures) } } for (const [handle, subscription] of resources.subscriptions) { const cleanup = await this.streams.removeSubscription(resources, handle, subscription, false) if (cleanup.state === 'release-failed') { failures.push(...cleanup.failures) } } for (const [handle, scan] of resources.scans) { const cleanup = await this.streams.stopScan(resources, handle, scan, false) if (cleanup.state === 'release-failed') { failures.push(...cleanup.failures) } } for (const [handle, connection] of resources.connections) { if (this.hasDependentResourcesForConnection(resources, handle)) { continue } const cleanup = await this.disconnectConnection(resources, handle, connection) if (cleanup.state === 'released') { this.deleteDatabasesForConnection(resources, handle) } else { failures.push(...cleanup.failures) } } if ( failures.length === 0 && resources.scans.size === 0 && resources.connectionEventSubscriptions.size === 0 && resources.subscriptions.size === 0 && resources.connections.size === 0 && resources.databases.size === 0 ) { return { state: 'released', failures: [] } } if (failures.length === 0) { failures.push({ resourceKind: 'renderer-resources', error: contractError('lifecycle.invariant-violation', 'cleanup', 'electron-main-router.release-incomplete') .normalized }) } return { state: 'release-failed', failures } } private async rollbackOperationResources( resources: RendererResources, snapshot: RendererResourceSnapshot ): Promise { const failures: CleanupFailure[] = [] for (const [handle, connectionEvents] of resources.connectionEventSubscriptions) { if (snapshot.connectionEventSubscriptions.has(handle)) { continue } const cleanup = await this.connectionEvents.remove(resources, handle, connectionEvents, true) if (cleanup.state === 'release-failed') { failures.push(...cleanup.failures) console.error('[ElectronMainBleRouter] Connection lifecycle stream rollback failed after oversized response:', { handle, cleanup }) } } for (const [handle, subscription] of resources.subscriptions) { if (snapshot.subscriptions.has(handle)) { continue } const cleanup = await this.streams.removeSubscription(resources, handle, subscription, false) if (cleanup.state === 'release-failed') { failures.push(...cleanup.failures) console.error('[ElectronMainBleRouter] Subscription rollback failed after oversized response:', { handle, cleanup }) } } for (const [handle, scan] of resources.scans) { if (snapshot.scans.has(handle)) { continue } const cleanup = await this.streams.stopScan(resources, handle, scan, false) if (cleanup.state === 'release-failed') { failures.push(...cleanup.failures) console.error('[ElectronMainBleRouter] Scan rollback failed after oversized response:', { handle, cleanup }) } } for (const [handle, connection] of resources.connections) { if (snapshot.connections.has(handle)) { continue } const cleanup = await this.disconnectConnection(resources, handle, connection) if (cleanup.state === 'release-failed') { failures.push(...cleanup.failures) console.error('[ElectronMainBleRouter] Connection rollback failed after oversized response:', { handle, cleanup }) } } for (const handle of resources.databases.keys()) { if (!snapshot.databases.has(handle)) { resources.databases.delete(handle) } } return failures.length === 0 ? { state: 'released', failures: [] } : { state: 'release-failed', failures } } private async rollbackOperationAdmission( resources: RendererResources, snapshot: RendererResourceSnapshot, envelope: IpcEnvelope ): Promise { const rollback = await this.rollbackOperationResources(resources, snapshot) if (envelope.command !== 'connection.events.ready') { return rollback } const handle = requiredString(envelope.payload, 'connectionEventsHandle') const resource = resources.connectionEventSubscriptions.get(handle) if (resource === undefined) { return rollback } const cleanup = await this.connectionEvents.remove(resources, handle, resource, true) if (cleanup.state === 'released') { return rollback } return { state: 'release-failed', failures: [...rollback.failures, ...cleanup.failures] } } private async disconnectConnection( resources: RendererResources, handle: string, connection: MainConnection ): Promise { const failures: CleanupFailure[] = [] if (!(await this.cleanupResource('connection', () => connection.disconnect(), failures))) { return { state: 'release-failed', failures } } resources.connections.delete(handle) return { state: 'released', failures: [] } } private async cleanupResource( resourceKind: string, cleanup: () => Promise, failures: CleanupFailure[] ): Promise { try { const result = await cleanup() failures.push(...result.failures) return result.state === 'released' } catch (error) { console.error('[ElectronMainBleRouter] Resource cleanup failed:', { resourceKind, error }) failures.push({ resourceKind, error: normalizedCleanupError(error) }) return false } } private async releaseSubscriptionsForConnection( resources: RendererResources, connectionHandle: string ): Promise { const failures: CleanupFailure[] = [] for (const [handle, resource] of resources.subscriptions) { if (!this.isSubscriptionForConnection(resources, resource, connectionHandle)) { continue } const cleanup = await this.streams.removeSubscription(resources, handle, resource, true) if (cleanup.state === 'release-failed') { failures.push(...cleanup.failures) } } return failures.length === 0 ? { state: 'released', failures: [] } : { state: 'release-failed', failures } } private async releaseConnectionEventSubscriptionsForConnection( resources: RendererResources, connectionHandle: string ): Promise { const failures: CleanupFailure[] = [] for (const [handle, resource] of resources.connectionEventSubscriptions) { if (resource.connectionHandle !== connectionHandle) { continue } const cleanup = await this.connectionEvents.remove(resources, handle, resource, true) if (cleanup.state === 'release-failed') { failures.push(...cleanup.failures) } } return failures.length === 0 ? { state: 'released', failures: [] } : { state: 'release-failed', failures } } private isSubscriptionForConnection( resources: RendererResources, subscription: ManagedSubscription, connectionHandle: string ): boolean { return resources.databases.get(subscription.databaseHandle)?.connectionHandle === connectionHandle } private hasDependentResourcesForConnection(resources: RendererResources, connectionHandle: string): boolean { for (const resource of resources.connectionEventSubscriptions.values()) { if (resource.connectionHandle === connectionHandle) { return true } } for (const subscription of resources.subscriptions.values()) { if (this.isSubscriptionForConnection(resources, subscription, connectionHandle)) { return true } } return false } private deleteDatabasesForConnection(resources: RendererResources, connectionHandle: string): void { for (const [databaseHandle, database] of resources.databases) { if (database.connectionHandle === connectionHandle) { resources.databases.delete(databaseHandle) } } } private async retireDatabasesForRediscovery( resources: RendererResources, connectionHandle: string ): Promise { const failures: CleanupFailure[] = [] for (const [databaseHandle, database] of resources.databases) { if (database.connectionHandle !== connectionHandle) { continue } let retired = true for (const [subscriptionHandle, subscription] of resources.subscriptions) { if (subscription.databaseHandle !== databaseHandle) { continue } const cleanup = await this.streams.removeSubscription(resources, subscriptionHandle, subscription, true) if (cleanup.state === 'release-failed') { retired = false failures.push(...cleanup.failures) } } if (retired) { resources.databases.delete(databaseHandle) } } return failures.length === 0 ? { state: 'released', failures: [] } : { state: 'release-failed', failures } } private resourcesFor(rendererLease: RendererLeaseIdentity): RendererResources { const key = String(rendererLease.leaseId) const existing = this.resources.get(key) if (existing !== undefined) { return existing } const resources: RendererResources = { rendererLease, scans: new Map(), connections: new Map(), connectionEventSubscriptions: new Map(), databases: new Map(), subscriptions: new Map(), operations: new Map(), preCancelledOperations: new Map(), settledOperations: new Map(), lifecycle: 'active', releaseResult: null } this.resources.set(key, resources) return resources } private allocateHandle( kind: 'scan' | 'connection' | 'characteristic' | 'descriptor' | 'database' | 'subscription' ): string { for (;;) { const handle = `${kind}-${this.nextHandle++}` if (!this.isAllocatedHandle(handle)) { return handle } } } private isAllocatedHandle(handle: string): boolean { if (isElectronConnectionEventsStreamHandle(handle)) { return true } for (const resources of this.resources.values()) { if ( resources.scans.has(handle) || resources.connections.has(handle) || resources.connectionEventSubscriptions.has(handle) || resources.databases.has(handle) || resources.subscriptions.has(handle) ) { return true } } return false } private pruneCancellationCorrelations(resources: RendererResources): number { const now = this.cancellationClock() pruneExpiredCorrelationRecords(resources.preCancelledOperations, now) pruneExpiredCorrelationRecords(resources.settledOperations, now) trimCorrelationRecords(resources.settledOperations, this.maximumOutstandingOperations) return now } private recordSettledOperation(resources: RendererResources, operationKey: string): void { const now = this.pruneCancellationCorrelations(resources) resources.settledOperations.set(operationKey, now + CANCELLATION_CORRELATION_TTL_MILLISECONDS) trimCorrelationRecords(resources.settledOperations, this.maximumOutstandingOperations) } private event(rendererLease: RendererLeaseIdentity, streamId: string, item: SerializableRecord): ElectronBleIpcEvent { return Object.freeze({ rendererLease, eventId: `event-${this.nextEvent++}`, streamId, item }) } } function createElectronHostIpcVersionAxes(core: HostNeutralBackendIdentity['versions']): IpcVersionAxes { const coreOffer = coreCompatibilityOffer(core) const ipcOffer = versionRange(version('ipc-protocol', 2), version('ipc-protocol', 2)) return Object.freeze({ ...negotiateCoreVersions(coreOffer, coreOffer), ipcProtocol: negotiateVersion(ipcOffer, ipcOffer) }) } export function createElectronIpcVersionAxes( core: HostNeutralBackendIdentity['versions'], remoteOffer: IpcCompatibilityOffer ): IpcVersionAxes { const coreOffer = coreCompatibilityOffer(core) const ipcOffer = versionRange(version('ipc-protocol', 2), version('ipc-protocol', 2)) return Object.freeze({ ...negotiateCoreVersions(coreOffer, remoteOffer), ipcProtocol: negotiateVersion(ipcOffer, remoteOffer.ipcProtocol) }) } function coreCompatibilityOffer(core: HostNeutralBackendIdentity['versions']): IpcCompatibilityOffer { return { backendContract: versionRange( version('backend-contract', core.backendContract.selected.value), version('backend-contract', core.backendContract.selected.value) ), capabilitySchema: versionRange( version('capability-schema', core.capabilitySchema.selected.value), version('capability-schema', core.capabilitySchema.selected.value) ), eventSchema: versionRange( version('event-schema', core.eventSchema.selected.value), version('event-schema', core.eventSchema.selected.value) ), traceFormat: versionRange( version('trace-format', core.traceFormat.selected.value), version('trace-format', core.traceFormat.selected.value) ), ipcProtocol: versionRange(version('ipc-protocol', 2), version('ipc-protocol', 2)) } } function rendererIdentity( sender: TrustedIpcSender ): RendererIdentity { return Object.freeze({ clientId: sender.authenticatedClientId, windowScope: sender.authenticatedWindowScope, sessionScope: sender.authenticatedSessionScope }) } function snapshotResourceHandles(resources: RendererResources): RendererResourceSnapshot { return { scans: new Set(resources.scans.keys()), connections: new Set(resources.connections.keys()), connectionEventSubscriptions: new Set(resources.connectionEventSubscriptions.keys()), databases: new Set(resources.databases.keys()), subscriptions: new Set(resources.subscriptions.keys()) } } function createManagedOperation(controller: AbortController): ManagedOperation { let complete = (): void => undefined const settled = new Promise(resolve => { complete = resolve }) return { controller, settled, complete } } function requiredString(payload: SerializableRecord, key: string): string { const value = payload[key] if (typeof value !== 'string' || value.length === 0) { throw contractError('protocol.malformed', 'ipc', `electron-main-router.${key}`) } return value } function requiredDeliveryMode( value: SerializableRecord[string] | undefined ): 'prefer-notification' | 'prefer-indication' | 'require-notification' | 'require-indication' | undefined { if (value === undefined) return undefined if ( value !== 'prefer-notification' && value !== 'prefer-indication' && value !== 'require-notification' && value !== 'require-indication' ) { throw contractError('argument.invalid', 'gatt', 'electron-main-router.delivery-mode') } return value } function deliveryFromPayload(payload: SerializableRecord): SubscriptionOptions['delivery'] { const itemCapacity = payload.streamItemCapacity const byteCapacity = payload.streamByteCapacity const reservedControlCapacity = payload.streamReservedControlCapacity const overflowPolicy = payload.streamOverflowPolicy if ( itemCapacity === undefined && byteCapacity === undefined && reservedControlCapacity === undefined && overflowPolicy === undefined ) { return DEFAULT_DELIVERY } if ( typeof itemCapacity !== 'number' || typeof byteCapacity !== 'number' || typeof reservedControlCapacity !== 'number' || (overflowPolicy !== 'latest' && overflowPolicy !== 'drop-oldest' && overflowPolicy !== 'drop-newest' && overflowPolicy !== 'error') ) { throw contractError('protocol.malformed', 'ipc', 'electron-main-router.delivery') } try { return Object.freeze({ itemCapacity: capacity(itemCapacity), byteCapacity: capacity(byteCapacity), reservedControlCapacity: capacity(reservedControlCapacity), overflowPolicy }) } catch { throw contractError('protocol.malformed', 'ipc', 'electron-main-router.delivery') } } function deadlineFromPayload(payload: SerializableRecord) { const value = payload.deadline if (value === null || value === undefined) { return null } if (typeof value !== 'number') { throw contractError('protocol.malformed', 'ipc', 'electron-main-router.deadline') } return deadline(value) } function operationAdmissionFailure( controller: AbortController, payload: SerializableRecord, monotonicNow: () => number, command: string ): BackendContractError | null { if (controller.signal.aborted) { return contractError('operation.aborted', 'ipc', `electron-main-router.${command}`) } const operationDeadline = deadlineFromPayload(payload) if (operationDeadline !== null && operationDeadline <= monotonicNow()) { return contractError('operation.timed-out', 'ipc', `electron-main-router.${command}`) } return null } function isDestructiveCleanupCommand(command: string): boolean { return ( command === 'scan.stop' || command === 'connection.disconnect' || command === 'connection.events.unsubscribe' || command === 'gatt.unsubscribe' || command === 'gatt.database.release' ) } function operationOptions(payload: SerializableRecord, controller: AbortController) { return Object.freeze({ signal: controller.signal, deadline: deadlineFromPayload(payload) }) } function optionalRediscoveryReason( payload: SerializableRecord ): Extract | null { const value = payload.rediscoveryReason if (value === undefined) { return null } if (value !== 'service-changed' && value !== 'manual-rediscovery') { throw contractError('protocol.malformed', 'ipc', 'electron-main-router.rediscovery-reason') } return value } function requiredWriteMode(payload: SerializableRecord): 'with-response' | 'without-response' { const mode = requiredString(payload, 'mode') if (mode !== 'with-response' && mode !== 'without-response') { throw contractError('argument.invalid', 'ipc', 'electron-main-router.write-mode') } return mode } function requiredResource(resources: Map, handle: string, kind: string): Value { const resource = resources.get(handle) if (resource === undefined) { throw contractError('ownership.denied', 'ipc', `electron-main-router.${kind}-ownership`) } return resource } function characteristicKey(path: { readonly serviceUuid: unknown readonly serviceOccurrence: unknown readonly characteristicUuid: unknown readonly characteristicOccurrence: unknown }): string { return [path.serviceUuid, path.serviceOccurrence, path.characteristicUuid, path.characteristicOccurrence] .map(value => String(value)) .join('|') } function characteristicProperties(characteristic: MainCharacteristicSnapshot): readonly string[] { const properties = characteristic.properties ?? { broadcast: false, read: false, writeWithResponse: false, writeWithoutResponse: false, authenticatedSignedWrites: false, notify: false, indicate: false, extendedProperties: false, reliableWrite: false, writableAuxiliaries: false } const entries: readonly (readonly [string, boolean | undefined])[] = [ ['broadcast', properties.broadcast], ['read', properties.read], ['write', properties.writeWithResponse], ['write-with-response', properties.writeWithResponse], ['write-without-response', properties.writeWithoutResponse], ['authenticated-signed-writes', properties.authenticatedSignedWrites], ['notify', properties.notify], ['indicate', properties.indicate], ['extended-properties', properties.extendedProperties], ['reliable-write', properties.reliableWrite], ['writable-auxiliaries', properties.writableAuxiliaries] ] return Object.freeze(entries.flatMap(([name, supported]) => (supported ? [name] : []))) } function discoveryDescriptor(capabilities: ReturnType): { readonly kind: 'continuous-scan' | 'system-chooser' | 'hybrid' } { const supports = (id: string): boolean => { const descriptor = capabilities.find(candidate => String(candidate.id) === id) return descriptor?.state === 'supported' || descriptor?.state === 'limited' } const continuous = supports('discovery:continuous-scan') const chooser = supports('discovery:system-chooser') if (continuous && chooser) return Object.freeze({ kind: 'hybrid' }) if (chooser) return Object.freeze({ kind: 'system-chooser' }) return Object.freeze({ kind: 'continuous-scan' }) } function serializeAccessRequirements(access: GattAccessRequirements): SerializableRecord { return Object.freeze({ read: access.read, write: access.write }) } function serializeCharacteristicProperties(properties: CharacteristicProperties): SerializableRecord { return Object.freeze({ broadcast: properties.broadcast, read: properties.read, writeWithResponse: properties.writeWithResponse, writeWithoutResponse: properties.writeWithoutResponse, authenticatedSignedWrites: properties.authenticatedSignedWrites, notify: properties.notify, indicate: properties.indicate, extendedProperties: properties.extendedProperties, reliableWrite: properties.reliableWrite, writableAuxiliaries: properties.writableAuxiliaries, availability: Object.freeze({ ...properties.availability }) }) } function serializeDescriptorProperties(properties: GattDescriptorProperties): SerializableRecord { return Object.freeze({ read: properties.read, write: properties.write, availability: Object.freeze({ ...properties.availability }), access: serializeAccessRequirements(properties.access) }) } function cleanupRecord(cleanup: CleanupRecord): SerializableRecord { return Object.freeze({ state: cleanup.state, failures: Object.freeze( cleanup.failures.map(failure => Object.freeze({ resourceKind: failure.resourceKind, error: serializeNormalizedError(failure.error) }) ) ) }) } function serializeNormalizedError(error: CleanupRecord['failures'][number]['error']): SerializableRecord { return Object.freeze({ code: error.code, domain: error.domain, operation: error.operation, retryability: error.retryability, platform: error.platform === null ? null : Object.freeze({ domain: error.platform.domain, code: error.platform.code, safeMessage: error.platform.safeMessage, metadata: error.platform.metadata }) }) } function serializeWriteReceipt( receipt: { readonly terminal: { readonly correlation: string readonly outcome: string readonly cause: string | null } readonly commitState: 'confirmed' | 'unknown' }, mode: 'with-response' | 'without-response', bytesSubmitted: number ): SerializableRecord { return Object.freeze({ terminal: Object.freeze({ correlation: receipt.terminal.correlation, outcome: receipt.terminal.outcome === 'succeeded' ? 'succeeded' : 'failed', cause: receipt.terminal.cause }), mode, commitState: receipt.commitState, bytesSubmitted }) } function normalizedCleanupError(error: unknown) { if (error instanceof BackendContractError) { return error.normalized } return contractError('platform.failure', 'cleanup', 'electron-main-router.cleanup').normalized } function pruneExpiredCorrelationRecords(records: Map, now: number): void { for (const [correlation, expiresAt] of records) { if (expiresAt <= now) { records.delete(correlation) } } } function trimCorrelationRecords(records: Map, maximumRecords: number): void { while (records.size > maximumRecords) { const oldest = records.keys().next().value if (oldest === undefined) { return } records.delete(oldest) } }