// src/web/web-bluetooth-handles.ts import type { BackendConnection, BackendSubscription, ConnectionLease, ScanLease } from '../backend-contract/backend' import type { AdvertisementObservation } from '../backend-contract/advertisement' import type { CleanupRecord } from '../backend-contract/errors' import type { Characteristic, CharacteristicPath, DatabasePath, Descriptor, DescriptorPath, GattDatabase, GattDatabaseSnapshot, NotificationValue, Service } from '../backend-contract/gatt' import { attachmentRecordsEqual, type AttachmentRecord } from '../backend-contract/identity' import type { OperationTerminalRecord, PublicOperationOptions, SubscriptionOptions, WritePolicy, WriteReceipt } from '../backend-contract/operations' import type { AttachmentId, ConnectionId, GenerationId, LeaseId, OwnedBytes, PeerId, ScanSessionId, SubscriptionId } from '../backend-contract/primitives' import type { BoundedAsyncStream } from '../backend-contract/streams' import type { CoreBoundedStream } from '../core/bounded-stream' import type { WebBluetoothCharacteristicBoundary, WebBluetoothDescriptorBoundary, WebBluetoothDeviceBoundary, WebBluetoothDisconnectListener, WebBluetoothNotificationListener } from './web-bluetooth-boundary' export interface WebGattDatabaseHost { readonly identity: { readonly attachment: AttachmentRecord } disconnectConnection(connection: WebBackendConnection): Promise disconnectRecord(record: WebConnectionRecord): Promise staleGattError(operation: string): Error readDirect( database: WebGattDatabase, path: CharacteristicPath, options: PublicOperationOptions ): Promise writeDirect( database: WebGattDatabase, path: CharacteristicPath, value: Readonly, options: WritePolicy ): Promise> readDescriptorDirect( database: WebGattDatabase, path: DescriptorPath, options: PublicOperationOptions ): Promise writeDescriptorDirect( database: WebGattDatabase, path: DescriptorPath, value: Readonly, options: WritePolicy ): Promise> subscribeDirect( database: WebGattDatabase, path: CharacteristicPath, options: SubscriptionOptions ): Promise> } export interface WebConnectionRecord { readonly peerId: PeerId readonly device: WebBluetoothDeviceBoundary readonly grantedServices: ReadonlySet readonly connection: WebBackendConnection readonly leaseId: LeaseId readonly disconnectListener: WebBluetoothDisconnectListener readonly disconnectWaiters: Set<() => void> database: WebGattDatabase | null valid: boolean subscriptionReleased: boolean physicalReleased: boolean disconnectPromise: Promise | null } export interface WebPendingConnection { readonly peerId: PeerId readonly device: WebBluetoothDeviceBoundary readonly grantedServices: ReadonlySet readonly ownershipToken: object nativeConnect: Promise state: 'connecting' | 'compensating' | 'cleanup-failed' cleanupFailureReported: boolean } export interface WebSelectedDevice { readonly peerId: PeerId readonly device: WebBluetoothDeviceBoundary readonly grantedServices: ReadonlySet } export class WebScanLease implements ScanLease { readonly shareToken = null constructor( readonly scanSessionId: ScanSessionId, readonly leaseId: LeaseId, readonly observations: BoundedAsyncStream>, private readonly stopOperation: () => Promise ) {} stop(): Promise { return this.stopOperation() } } export class WebBackendConnection implements BackendConnection { private connectionState: BackendConnection['state'] = 'connected' constructor( private readonly backend: WebGattDatabaseHost, readonly peerId: PeerId, readonly connectionId: ConnectionId, readonly connectionGeneration: GenerationId<'connection-generation', string> ) {} get attachment(): AttachmentRecord { return this.backend.identity.attachment } get attachmentId(): AttachmentId { return this.attachment.attachmentId } get state(): BackendConnection['state'] { return this.connectionState } disconnect(): Promise { return this.backend.disconnectConnection(this) } transition(state: BackendConnection['state']): void { this.connectionState = state } } export class WebConnectionLease implements ConnectionLease { readonly connection: BackendConnection private releaseResult: Promise | null = null constructor( private readonly backend: WebGattDatabaseHost, readonly record: WebConnectionRecord, readonly leaseId: LeaseId ) { this.connection = record.connection } release(): Promise { if (this.releaseResult === null) { const release = this.backend.disconnectRecord(this.record) this.releaseResult = release.then( result => { if (result.state === 'release-failed') { this.releaseResult = null } return result }, error => { this.releaseResult = null throw error } ) } return this.releaseResult } } export class WebGattDatabase implements GattDatabase { private valid = true constructor( private readonly backend: WebGattDatabaseHost, readonly record: WebConnectionRecord, readonly path: DatabasePath, private readonly services: readonly Service[], private readonly characteristics: readonly Characteristic[], private readonly descriptors: readonly Descriptor[], readonly characteristicBoundaries: ReadonlyMap, readonly descriptorBoundaries: ReadonlyMap ) {} async snapshot(): Promise> { this.assertCurrent('web-gatt.snapshot') return { path: this.path, services: [...this.services], characteristics: [...this.characteristics], descriptors: [...this.descriptors] } } read( path: CharacteristicPath, options: PublicOperationOptions ): Promise { return this.backend.readDirect(this, path, options) } write( path: CharacteristicPath, value: Readonly, options: WritePolicy ): Promise> { return this.backend.writeDirect(this, path, value, options) } readDescriptor( path: DescriptorPath, options: PublicOperationOptions ): Promise { return this.backend.readDescriptorDirect(this, path, options) } writeDescriptor( path: DescriptorPath, value: Readonly, options: WritePolicy ): Promise> { return this.backend.writeDescriptorDirect(this, path, value, options) } subscribe( path: CharacteristicPath, options: SubscriptionOptions ): Promise> { return this.backend.subscribeDirect(this, path, options) } assertCurrent(operation: string): void { if (!this.valid || !this.record.valid || this.record.database !== this) { throw this.backend.staleGattError(operation) } } assertPath( path: | CharacteristicPath | DescriptorPath, operation: string ): void { this.assertCurrent(operation) if ( path.validity !== 'current' || !attachmentRecordsEqual(path.attachment, this.backend.identity.attachment) || path.attachmentId !== this.backend.identity.attachment.attachmentId || !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 ) { throw this.backend.staleGattError(operation) } } invalidate(): void { this.valid = false } } export interface WebManagedSubscription { readonly subscriptionId: SubscriptionId readonly path: CharacteristicPath readonly database: WebGattDatabase readonly characteristic: WebBluetoothCharacteristicBoundary readonly listener: WebBluetoothNotificationListener readonly stream: CoreBoundedStream readonly terminal: OperationTerminalRecord state: 'enabling' | 'ready' | 'stopping' | 'cleanup-failed' | 'stopped' startupSettled: boolean cleanupFailureReported: boolean removeResult: Promise | null terminationCleanup: Promise | null } export class WebBackendSubscription implements BackendSubscription { constructor(private readonly managed: WebManagedSubscription) {} get subscriptionId(): SubscriptionId { return this.managed.subscriptionId } get path(): CharacteristicPath { return this.managed.path } get terminal(): OperationTerminalRecord { return this.managed.terminal } get notifications(): BoundedAsyncStream { return this.managed.stream } isManagedBy(managed: WebManagedSubscription): boolean { return this.managed === managed } } export function characteristicKey(path: CharacteristicPath): string { return [ path.connectionId, path.databaseId, path.databaseGeneration, path.serviceUuid, path.serviceOccurrence, path.characteristicUuid, path.characteristicOccurrence ] .map(String) .join('\u0000') } export function descriptorKey(path: DescriptorPath): string { return `${characteristicKey(path)}\u0000${String(path.descriptorUuid)}\u0000${String(path.descriptorOccurrence)}` }