// src/backends/corebluetooth/corebluetooth-handles.ts import { advertisementMatchesFilter, type AdvertisementObservation, type OwnerScanOptions } from '../../backend-contract/advertisement' import type { BackendConnection, BackendSubscription, ConnectionLease, ScanLease } from '../../backend-contract/backend' import { BackendContractError, contractError, type CleanupFailure, type CleanupRecord } from '../../backend-contract/errors' import { createGattCharacteristicProperties, createGattDescriptorProperties, type Characteristic, type CharacteristicPath, type DatabasePath, type Descriptor, type DescriptorPath, type GattDatabase, type GattDatabaseSnapshot, type NotificationValue, type Service } from '../../backend-contract/gatt' import { attachmentRecordsEqual, type AttachmentRecord } from '../../backend-contract/identity' import type { OperationOptions, OperationTerminalRecord, PublicOperationOptions } from '../../backend-contract/operations' import { canonicalUuid, opaqueId, type AttachmentId, type ConnectionId, type GenerationId, type LeaseId, type OwnedBytes, type PeerId, type ScanSessionId, type ScanShareToken, type SubscriptionId } from '../../backend-contract/primitives' import type { BoundedAsyncStream } from '../../backend-contract/streams' import { CoreBoundedStream } from '../../core/bounded-stream' import type { CoreBluetoothCharacteristicAddress, CoreBluetoothDescriptorAddress, CoreBluetoothGattSnapshot, CoreBluetoothNotificationDeliveryMode } from './corebluetooth-boundary' import type { ConnectionRecord, CoreBluetoothBackend, PhysicalSubscription, ScanConsumer } from './corebluetooth-backend' export const releasedCleanup: CleanupRecord = Object.freeze({ state: 'released', failures: Object.freeze([]) }) export class CoreBluetoothScanLease implements ScanLease { readonly scanSessionId: ScanSessionId readonly leaseId: LeaseId readonly shareToken: ScanShareToken | null readonly observations: BoundedAsyncStream> constructor( private readonly backend: CoreBluetoothBackend, consumer: ScanConsumer ) { this.scanSessionId = consumer.scanSessionId this.leaseId = consumer.leaseId this.shareToken = consumer.shareToken this.observations = consumer.stream this.consumer = consumer } private readonly consumer: ScanConsumer stop(): Promise { return this.backend.stopScanConsumer(this.consumer) } } export class CoreBluetoothConnection implements BackendConnection { constructor( private readonly backend: CoreBluetoothBackend, readonly record: ConnectionRecord ) {} get attachment(): AttachmentRecord { return this.backend.attachment() } get attachmentId(): AttachmentId { return this.attachment.attachmentId } get peerId(): PeerId { return this.record.peerId } get connectionId(): ConnectionId { return this.record.connectionId } get connectionGeneration(): GenerationId<'connection-generation', string> { return this.record.connectionGeneration } get state(): BackendConnection['state'] { return this.record.state === 'cleanup-failed' ? 'connected' : this.record.state } disconnect(): Promise { return this.backend.disconnect(this.record, 'direct-gatt.connection.disconnect') } } export class CoreBluetoothConnectionLease implements ConnectionLease { private released = false private releaseResult: Promise | null = null constructor( private readonly backend: CoreBluetoothBackend, readonly record: ConnectionRecord, readonly connection: CoreBluetoothConnection ) {} get leaseId(): LeaseId { return this.record.ownerLeaseId } release(): Promise { if (this.released) { return Promise.resolve(releasedCleanup) } if (this.releaseResult === null) { this.releaseResult = this.backend.releaseConnectionLease(this).then(result => { if (result.state === 'released') { this.released = true } else { this.releaseResult = null } return result }) } return this.releaseResult } markReleased(): void { this.released = true } } export class CoreBluetoothGattDatabase implements GattDatabase { private valid = true constructor( private readonly backend: CoreBluetoothBackend, private readonly record: ConnectionRecord, readonly path: DatabasePath, private readonly snapshotRecord: CoreBluetoothGattSnapshot ) {} async snapshot(): Promise> { this.assertCurrent('direct-gatt.gatt.snapshot') const services: Service[] = [] const characteristics: Characteristic[] = [] const descriptors: Descriptor[] = [] for (const service of this.snapshotRecord.services) { const servicePath = Object.freeze({ ...this.path, serviceUuid: canonicalUuid(service.uuid), serviceOccurrence: opaqueId(String(service.occurrence), 'service-occurrence', String(this.path.databaseId)) }) services.push(Object.freeze({ path: servicePath, primary: true, includedServices: Object.freeze([]) })) for (const characteristic of service.characteristics) { const characteristicPath: CharacteristicPath = Object.freeze( { ...servicePath, characteristicUuid: canonicalUuid(characteristic.uuid), characteristicOccurrence: opaqueId( String(characteristic.occurrence), 'characteristic-occurrence', String(servicePath.serviceOccurrence) ), validity: 'current' } ) characteristics.push( Object.freeze({ path: characteristicPath, properties: createGattCharacteristicProperties({ read: characteristic.readable, writeWithResponse: characteristic.writableWithResponse, writeWithoutResponse: characteristic.writableWithoutResponse, notify: characteristic.notifiable, indicate: characteristic.indicatable }), access: Object.freeze({ read: 'unknown', write: 'unknown' }) }) ) for (const descriptor of characteristic.descriptors) { const descriptorPath: DescriptorPath = Object.freeze({ ...characteristicPath, descriptorUuid: canonicalUuid(descriptor.uuid), descriptorOccurrence: opaqueId( String(descriptor.occurrence), 'descriptor-occurrence', String(characteristicPath.characteristicOccurrence) ) }) descriptors.push( Object.freeze({ path: descriptorPath, properties: createGattDescriptorProperties( false, false, { read: 'unknown', write: 'unknown' }, { read: 'unknown', write: 'unknown' } ) }) ) } } } return Object.freeze({ path: this.path, services: Object.freeze(services), characteristics: Object.freeze(characteristics), descriptors: Object.freeze(descriptors) }) } async read( path: CharacteristicPath, options: PublicOperationOptions ): Promise { this.assertCurrent('direct-gatt.gatt.database-read') const address = this.addressFor(path, 'direct-gatt.gatt.database-read') return this.backend.gattOperations.readFromDatabase(address, options, String(this.path.connectionId)) } async write( path: CharacteristicPath, value: Uint8Array, options: import('../../backend-contract/operations').WritePolicy ): Promise> { this.assertCurrent('direct-gatt.gatt.database-write') const address = this.addressFor(path, 'direct-gatt.gatt.database-write') await this.backend.gattOperations.writeFromDatabase( address, value, options.mode === 'with-response', options, String(this.path.connectionId) ) return Object.freeze({ terminal: Object.freeze({ correlation: opaqueId('corebluetooth-database-write', 'core-operation', 'corebluetooth:database'), outcome: 'succeeded', cause: null }), commitState: 'confirmed' }) } async readDescriptor< ServiceOccurrence extends string, CharacteristicOccurrence extends string, DescriptorOccurrence extends string >( path: DescriptorPath< string, string, string, ServiceOccurrence, CharacteristicOccurrence, DescriptorOccurrence, 'current' >, options: PublicOperationOptions ): Promise { this.assertCurrent('direct-gatt.gatt.database-read-descriptor') const address = this.descriptorAddressFor(path, 'direct-gatt.gatt.database-read-descriptor') return this.backend.gattOperations.readDescriptorFromDatabase(address, options, String(this.path.connectionId)) } async writeDescriptor< ServiceOccurrence extends string, CharacteristicOccurrence extends string, DescriptorOccurrence extends string >( path: DescriptorPath< string, string, string, ServiceOccurrence, CharacteristicOccurrence, DescriptorOccurrence, 'current' >, value: Uint8Array, options: import('../../backend-contract/operations').WritePolicy ): Promise> { this.assertCurrent('direct-gatt.gatt.database-write-descriptor') const address = this.descriptorAddressFor(path, 'direct-gatt.gatt.database-write-descriptor') await this.backend.gattOperations.writeDescriptorFromDatabase( address, value, options, String(this.path.connectionId) ) return Object.freeze({ terminal: Object.freeze({ correlation: opaqueId('corebluetooth-database-write-descriptor', 'core-operation', 'corebluetooth:database'), outcome: 'succeeded', cause: null }), commitState: 'confirmed' }) } async subscribe( path: CharacteristicPath, options: import('../../backend-contract/operations').SubscriptionOptions ): Promise { this.assertCurrent('direct-gatt.gatt.database-subscribe') this.addressFor(path, 'direct-gatt.gatt.database-subscribe') return this.backend.gattOperations.subscribeFromDatabase(path, options) } notificationDeliveryModeFor( path: CharacteristicPath, operation: string, requested: import('../../backend-contract/operations').SubscriptionOptions['deliveryMode'] ): CoreBluetoothNotificationDeliveryMode { this.assertCurrent(operation) if (!this.matchesPath(path)) { throw contractError('gatt.stale-handle', 'gatt', operation) } const service = this.snapshotRecord.services.find( candidate => candidate.uuid === path.serviceUuid && candidate.occurrence === Number(path.serviceOccurrence) ) const characteristic = service?.characteristics.find( candidate => candidate.uuid === path.characteristicUuid && candidate.occurrence === Number(path.characteristicOccurrence) ) if (characteristic === undefined) { throw contractError('gatt.not-found', 'gatt', operation) } const notificationAvailable = characteristic.notifiable const indicationAvailable = characteristic.indicatable === true if ( (requested === 'require-notification' && !notificationAvailable) || (requested === 'require-indication' && !indicationAvailable) || (!notificationAvailable && !indicationAvailable) ) { throw contractError('gatt.property-not-supported', 'gatt', operation) } if (requested === 'prefer-indication' || requested === 'require-indication') { return indicationAvailable ? 'indication' : 'notification' } return notificationAvailable ? 'notification' : 'indication' } invalidate(): void { this.valid = false } assertCurrent(operation: string): void { if (!this.valid || this.record.database !== this || this.record.state !== 'connected') { throw contractError('gatt.stale-handle', 'gatt', operation) } } matchesPath(path: CharacteristicPath): boolean { return ( attachmentRecordsEqual(path.attachment, this.path.attachment) && path.attachmentId === this.path.attachmentId && path.peerId === this.path.peerId && path.connectionId === this.path.connectionId && path.ownerLeaseId === this.path.ownerLeaseId && path.connectionGeneration === this.path.connectionGeneration && path.databaseId === this.path.databaseId && path.databaseGeneration === this.path.databaseGeneration && path.validity === 'current' ) } addressFor( path: CharacteristicPath, operation: string ): CoreBluetoothCharacteristicAddress { this.assertCurrent(operation) if (!this.matchesPath(path)) { throw contractError('gatt.stale-handle', 'gatt', operation) } const service = this.snapshotRecord.services.find( candidate => candidate.uuid === path.serviceUuid && candidate.occurrence === Number(path.serviceOccurrence) ) if (service === undefined) { throw contractError('gatt.not-found', 'gatt', operation) } const characteristic = service.characteristics.find( candidate => candidate.uuid === path.characteristicUuid && candidate.occurrence === Number(path.characteristicOccurrence) ) if (characteristic === undefined) { throw contractError('gatt.not-found', 'gatt', operation) } return Object.freeze({ nativePeerId: this.record.nativePeerId, serviceUuid: service.uuid, serviceOccurrence: service.occurrence, characteristicUuid: characteristic.uuid, characteristicOccurrence: characteristic.occurrence }) } descriptorAddressFor( path: DescriptorPath, operation: string ): CoreBluetoothDescriptorAddress { const characteristic = this.addressFor(path, operation) const service = this.snapshotRecord.services.find( candidate => candidate.uuid === path.serviceUuid && candidate.occurrence === Number(path.serviceOccurrence) ) const characteristicRecord = service?.characteristics.find( candidate => candidate.uuid === path.characteristicUuid && candidate.occurrence === Number(path.characteristicOccurrence) ) const descriptor = characteristicRecord?.descriptors.find( candidate => candidate.uuid === path.descriptorUuid && candidate.occurrence === Number(path.descriptorOccurrence) ) if (descriptor === undefined) { throw contractError('gatt.not-found', 'gatt', operation) } return Object.freeze({ ...characteristic, descriptorUuid: descriptor.uuid, descriptorOccurrence: descriptor.occurrence }) } } export class CoreBluetoothBackendSubscription implements BackendSubscription { removed = false constructor( private readonly backend: CoreBluetoothBackend, readonly physical: PhysicalSubscription, readonly path: CharacteristicPath, readonly subscriptionId: SubscriptionId, readonly terminal: OperationTerminalRecord, readonly stream: CoreBoundedStream ) {} get notifications(): BoundedAsyncStream { return this.stream } get values(): BoundedAsyncStream { return this.stream } remove(): Promise { return this.backend.gattOperations.removeSubscription(this) } isOwnedBy(backend: CoreBluetoothBackend): boolean { return this.backend === backend } } export function successfulTerminal( operation: OperationOptions ): OperationTerminalRecord { return Object.freeze({ correlation: operation.correlation, outcome: 'succeeded', cause: null }) } export function matchesScan( options: OwnerScanOptions, observation: AdvertisementObservation ): boolean { return advertisementMatchesFilter(options.filter, observation) } export function advertisementByteLength(observation: AdvertisementObservation): number { let size = 64 if (observation.localName.state === 'present') { size += observation.localName.value.length } if (observation.serviceUuids.state === 'present') { size += observation.serviceUuids.value.length * 36 } if (observation.solicitedServiceUuids.state === 'present') { size += observation.solicitedServiceUuids.value.length * 36 } if (observation.overflowServiceUuids.state === 'present') { size += observation.overflowServiceUuids.value.length * 36 } if (observation.rawRecord.state === 'present') { size += observation.rawRecord.value.byteLength } if (observation.scanResponseRecord.state === 'present') { size += observation.scanResponseRecord.value.byteLength } if (observation.serviceData.state === 'present') { for (const entry of observation.serviceData.value) { size += 36 + entry.value.byteLength } } if (observation.manufacturerData.state === 'present') { for (const entry of observation.manufacturerData.value) { size += 2 + entry.value.byteLength } } return size } export function addressKey(address: CoreBluetoothCharacteristicAddress): string { return [ address.nativePeerId, address.serviceUuid, String(address.serviceOccurrence), address.characteristicUuid, String(address.characteristicOccurrence) ].join('\u0000') } export function connectionPathFor(attachment: AttachmentRecord, record: ConnectionRecord) { return Object.freeze({ attachment, attachmentId: attachment.attachmentId, peerId: record.peerId, connectionId: record.connectionId, ownerLeaseId: record.ownerLeaseId, connectionGeneration: record.connectionGeneration }) } export function cleanupFailure(resourceKind: string, operation: string, error: unknown): CleanupRecord { return Object.freeze({ state: 'release-failed', failures: Object.freeze([cleanupFailureDetail(resourceKind, operation, error)]) }) } export function cleanupFailureDetail(resourceKind: string, operation: string, error: unknown): CleanupFailure { if (error instanceof BackendContractError && error.normalized.domain === 'cleanup') { return Object.freeze({ resourceKind, error: error.normalized }) } const safeMessage = error instanceof Error ? error.message : 'CoreBluetooth cleanup rejected with a non-Error value' const nativePlatform = error instanceof BackendContractError ? error.normalized.platform : null return Object.freeze({ resourceKind, error: contractError('platform.failure', 'cleanup', operation, { domain: nativePlatform?.domain ?? 'corebluetooth', code: nativePlatform?.code ?? 'native-cleanup-failed', safeMessage: nativePlatform?.safeMessage ?? safeMessage, metadata: nativePlatform?.metadata ?? Object.freeze({}) }).normalized }) }