// src/web/web-bluetooth-backend.ts import type { BackendAttachment, BackendConnection, BackendEvent, BackendPeerRecord, BackendPeerQuery, BleCentralBackend, ConnectionLease, GattBackend, PeerDirectoryBackend, ResourceCounters } from '../backend-contract/backend' import { BackendContractError, contractError } from '../backend-contract/errors' import type { BleErrorCode, CleanupFailure, CleanupRecord } from '../backend-contract/errors' import type { AdapterDescriptor, AdapterStateSnapshot, AdapterStateWatch, AttachmentRecord, BackendProvider, HostNeutralBackendIdentity, ProviderDescriptor } from '../backend-contract/identity' import { attachmentRecordsEqual } from '../backend-contract/identity' import type { CharacteristicPath, DescriptorPath } from '../backend-contract/gatt' import type { PublicOperationOptions, SubscriptionOptions, WritePolicy, WriteReceipt } from '../backend-contract/operations' import { canonicalUuid, capacity, createAttachmentBoundIdFactory, monotonicTimestamp, negotiateCoreVersions, opaqueId, resourceCount, version, versionRange } from '../backend-contract/primitives' import type { AdapterId, AttachmentBinding, BackendCompatibilityOffer, Deadline, GenerationId, HostNeutralVersionAxes, OwnedBytes, PeerId } from '../backend-contract/primitives' import type { BoundedAsyncStream, StreamLimits } from '../backend-contract/streams' import type { ScanFilter } from '../backend-contract/advertisement' import type { ChooserRequest, ChooserSelection, WebChooser } from '../backend-contract/host/web' import { assertPeerReference, encodePeerReference } from '../backend-contract/peer-reference' import type { PeerReference } from '../backend-contract/peer-reference' import { CoreBoundedStream } from '../core/bounded-stream' import type { WebBluetoothBoundary, WebBluetoothDeviceSelection, WebBluetoothRequestDeviceOptions, WebBluetoothRequestFilter, WebBluetoothTimerHandle } from './web-bluetooth-boundary' import { normalizeWebBluetoothError, validateWebChooserRequest, webCleanupFailure } from './web-bluetooth-errors' import { createWebBluetoothFeatureRegistry } from './web-feature-registry' import { diagnosticWebBluetoothScanPlan } from './web-bluetooth-scan-planner' import { WebBluetoothGattRuntime } from './web-bluetooth-gatt' import { WebBackendConnection, WebConnectionLease, WebGattDatabase } from './web-bluetooth-handles' import type { WebConnectionRecord, WebPendingConnection, WebSelectedDevice } from './web-bluetooth-handles' const WEB_ATTACHMENT = 'web-bluetooth' const WEB_ADAPTER_ID = opaqueId('web-bluetooth-default', 'adapter', WEB_ATTACHMENT) const DEFAULT_STREAM_LIMITS: StreamLimits = { itemCapacity: capacity(32), byteCapacity: capacity(512 * 1024), reservedControlCapacity: capacity(1) } /** * Shared availability observation cadence. This is not a power-state poll: * Web Bluetooth `getAvailability()` answers whether the API can run, not * whether the radio is physically on. One timer is shared by all adapter * watches and is released with the last owner. When the browser exposes * `onavailabilitychanged`, watches still fall back to this cadence if no * `availabilitychanged` event arrives within the same bound. */ const WEB_ADAPTER_AVAILABILITY_POLL_INTERVAL_MS = 500 const LOCAL_COMPATIBILITY: BackendCompatibilityOffer = { backendContract: versionRange(version('backend-contract', 1), version('backend-contract', 1)), capabilitySchema: versionRange(version('capability-schema', 1), version('capability-schema', 1)), eventSchema: versionRange(version('event-schema', 1), version('event-schema', 1)), traceFormat: versionRange(version('trace-format', 1), version('trace-format', 1)) } const RELEASED: CleanupRecord = { state: 'released', failures: [] } function mergeWebCleanupPhases(phases: readonly CleanupRecord[]): CleanupRecord { const failures: CleanupFailure[] = [] for (const phase of phases) { if (phase.state === 'release-failed') failures.push(...phase.failures) } return failures.length === 0 ? RELEASED : { state: 'release-failed', failures } } let nextBackendInstance = 1 interface AbortableOperation { readonly signal: AbortSignal | null readonly deadline: Deadline | null } interface WebProviderOptions { readonly boundary: WebBluetoothBoundary } /** Explicit Web Bluetooth provider. Importing this module never reads DOM globals. */ export class WebBluetoothProvider implements BackendProvider> { readonly descriptor: ProviderDescriptor = { providerId: 'unified-ble:web-bluetooth', hostKind: 'browser', loadability: 'loadable', compatibility: LOCAL_COMPATIBILITY } constructor(private readonly options: WebProviderOptions) {} async listAdapters(): Promise[]> { const available = await this.options.boundary.bluetoothAvailable() return [webAdapterDescriptor(this.options.boundary, available)] } async create(selection: { readonly selectedAdapterId: AdapterId }): Promise { if (selection.selectedAdapterId !== WEB_ADAPTER_ID) { throw contractError('adapter.unavailable', 'adapter', 'web-provider.create') } const available = await this.options.boundary.bluetoothAvailable() if (!available) { throw contractError('adapter.unavailable', 'adapter', 'web-provider.create') } return new WebBluetoothBackend(this.options.boundary, available) } } export function createWebBluetoothProvider(boundary: WebBluetoothBoundary): WebBluetoothProvider { return new WebBluetoothProvider({ boundary }) } export interface WebAuthorizedPeer { readonly peerId: PeerId readonly browserDeviceId: string readonly connected: boolean } /** Contract-v1 Web Bluetooth backend with chooser-limited discovery semantics. */ export class WebBluetoothBackend implements BleCentralBackend>, WebChooser { readonly features readonly adapter: BleCentralBackend>['adapter'] readonly scanner: BleCentralBackend>['scanner'] readonly connections: BleCentralBackend>['connections'] readonly gatt: GattBackend readonly peers: PeerDirectoryBackend = { resolve: (reference, options) => this.resolveAuthorizedPeer(reference, options), known: options => this.peerRecords(options), connected: async options => (await this.peerRecords(options)).filter(record => record.state.connection === 'connected'), bonded: async () => { throw contractError('capability.unsupported', 'connection', 'web-peer-directory.bonded') }, authorized: options => this.peerRecords(options), restored: async () => { throw contractError('capability.unsupported', 'connection', 'web-peer-directory.restored') } } private readonly backendInstance: number private attachmentRecord: AttachmentRecord private attachmentGeneration = 1 private negotiatedVersions: HostNeutralVersionAxes private attached = false private destroyed = false private chooserBusy = false private nextPeer = 1 private nextConnection = 1 private ingressOrdinal = 1 private destroyResult: Promise | null = null private readonly selectedDevices = new Map() private readonly peerByBrowserDeviceId = new Map>() private readonly connectionsByPeer = new Map() private readonly retainedConnections = new Set() private readonly pendingConnectionsByPeer = new Map() private readonly eventStreams = new Set>>() private readonly adapterStreams = new Set>>() private readonly destroyWaiters = new Set<() => void>() private readonly gattRuntime: WebBluetoothGattRuntime private removePageLifecycleListener: (() => void) | null private removeAvailabilityChangeListener: (() => void) | null = null private availabilityPollTimer: WebBluetoothTimerHandle | null = null private availabilityEventSilenceTimer: WebBluetoothTimerHandle | null = null private availabilityChangeObserved = false private availabilitySampleGeneration = 0 private appliedAvailabilitySampleGeneration = 0 constructor( private readonly boundary: WebBluetoothBoundary, initialAvailability = false ) { this.backendInstance = nextBackendInstance nextBackendInstance += 1 this.attachmentRecord = this.createAttachmentRecord(initialAvailability) this.negotiatedVersions = negotiateCoreVersions(LOCAL_COMPATIBILITY, LOCAL_COMPATIBILITY) this.features = createWebBluetoothFeatureRegistry( boundary.implementationVersion, boundary.getAuthorizedDevices !== undefined ) this.adapter = { currentState: async () => this.currentAdapterState(), watchState: async () => this.watchAdapterState() } this.scanner = { plan: query => diagnosticWebBluetoothScanPlan(query), start: async () => { this.assertAttached('web-scanner.start') throw contractError('capability.unsupported', 'scan', 'web-scanner.start') }, join: async () => { this.assertAttached('web-scanner.join') throw contractError('capability.unsupported', 'scan', 'web-scanner.join') } } this.connections = { connect: async (peerId, _clientId, options) => this.connect(peerId, options) } this.gattRuntime = new WebBluetoothGattRuntime(this) this.gatt = this.gattRuntime.gatt this.removePageLifecycleListener = boundary.addPageLifecycleListener(reason => { const cleanup = this.destroy() cleanup.then(result => { if (result.state === 'release-failed') { console.error(`[WebBluetoothBackend.pageLifecycle] ${reason} cleanup failed:`, result.failures) } }) }) } get identity(): HostNeutralBackendIdentity { return { registeredBackendId: 'unified-ble:web-bluetooth', registeredPlatformId: `web:${this.boundary.browserEngine}`, attachment: this.attachmentRecord, versions: this.negotiatedVersions, runtime: { hostKind: 'browser', implementationVersion: this.boundary.implementationVersion, diagnostics: { browserEngine: this.boundary.browserEngine, chooserDiscovery: true, continuousScan: false, backgroundOperation: false, stateRestoration: false } } } } get attachment(): AttachmentRecord { return this.attachmentRecord } async attach(request: { readonly coreCompatibility: BackendCompatibilityOffer }): Promise>> { this.assertUsable('web-backend.attach') if (this.attached) { throw contractError('lifecycle.invalid-state', 'core', 'web-backend.attach') } const generation = this.beginAvailabilitySample() const available = await this.boundary.bluetoothAvailable() this.assertUsable('web-backend.attach') if (this.attached) { throw contractError('lifecycle.invalid-state', 'core', 'web-backend.attach') } this.applyAvailabilitySample(generation, available) if (this.attachmentRecord.adapter.state.availability !== 'available') { throw contractError('adapter.unavailable', 'adapter', 'web-backend.attach') } const retainedCleanup = await this.releaseRetainedSessionResources() if (retainedCleanup.state === 'release-failed') { throw contractError('lifecycle.invalid-state', 'core', 'web-backend.attach') } this.assertUsable('web-backend.attach') if (this.attached) { throw contractError('lifecycle.invalid-state', 'core', 'web-backend.attach') } this.negotiatedVersions = negotiateCoreVersions(LOCAL_COMPATIBILITY, request.coreCompatibility) this.attached = true return { attachment: this.attachmentRecord, identity: this.identity } } async choose(request: ChooserRequest, options: PublicOperationOptions): Promise> { const selection = await this.chooseDevice(request, options) return { peerId: selection.peerId, grantedServices: [...selection.grantedServices].map(canonicalUuid) } } async authorizedPeers( options: PublicOperationOptions = { signal: null, deadline: null } ): Promise { this.assertAttached('web-peer-directory.authorized') this.assertAbortableAdmission(options, 'connection', 'web-peer-directory.authorized') if (options.deadline !== null && this.boundary.now() >= options.deadline) { throw contractError('operation.timed-out', 'connection', 'web-peer-directory.authorized') } const getAuthorizedDevices = this.boundary.getAuthorizedDevices if (getAuthorizedDevices === undefined) { throw contractError('capability.unsupported', 'connection', 'web-peer-directory.authorized') } const devices = await this.runAbortable( null, options, getAuthorizedDevices, 'connection.failed', 'connection', 'web-peer-directory.authorized' ) this.assertAbortableAdmission(options, 'connection', 'web-peer-directory.authorized') return devices.map(device => { const selected = this.rememberSelection({ device, grantedServices: [] }) return Object.freeze({ peerId: selected.peerId, browserDeviceId: device.id, connected: device.gatt.connected }) }) } private async peerRecords(options: BackendPeerQuery): Promise[]> { if (options.services !== undefined && options.services.length > 0) { throw contractError('capability.unsupported', 'connection', 'web-peer-directory.services') } if (options.sources !== undefined && !options.sources.includes('origin-authorized')) return Object.freeze([]) const records = await this.authorizedPeers(options) const references = options.references?.map((reference, index) => { assertPeerReference(reference, `web-peer-directory.references[${index}]`) if (reference.backendId !== this.identity.registeredBackendId || reference.scope !== 'origin') { throw contractError('peer.scope-mismatch', 'connection', 'web-peer-directory.reference') } return encodePeerReference(reference) }) return Object.freeze( records .map(record => this.toBackendPeerRecord(record)) .filter(record => references === undefined || references.includes(encodePeerReference(record.reference))) ) } private async resolveAuthorizedPeer( reference: PeerReference, options: BackendPeerQuery ): Promise | null> { assertPeerReference(reference, 'web-peer-directory.resolve') if (reference.backendId !== this.identity.registeredBackendId || reference.scope !== 'origin') { throw contractError('peer.scope-mismatch', 'connection', 'web-peer-directory.resolve') } const records = await this.peerRecords(options) return records.find(record => encodePeerReference(record.reference) === encodePeerReference(reference)) ?? null } private toBackendPeerRecord(record: WebAuthorizedPeer): BackendPeerRecord { return Object.freeze({ reference: Object.freeze({ version: 1, backendId: this.identity.registeredBackendId, scope: 'origin', opaqueId: record.browserDeviceId }), peerId: record.peerId, name: null, rssi: null, source: 'origin-authorized', state: Object.freeze({ reachability: record.connected ? 'reachable' : 'unknown', connection: record.connected ? 'connected' : 'disconnected', bond: 'unsupported', lastSeenAtMonotonicMs: null }) }) } peerReferenceFor(peerId: string): { readonly backendId: string; readonly browserDeviceId: string } | null { const selected = this.selectedDevices.get(peerId) return selected === undefined ? null : Object.freeze({ backendId: this.identity.registeredBackendId, browserDeviceId: selected.device.id }) } events(): BoundedAsyncStream> { this.assertUsable('web-backend.events') const stream = new CoreBoundedStream>(DEFAULT_STREAM_LIMITS, 'error') this.eventStreams.add(stream) return managedStream(stream, () => this.eventStreams.delete(stream)) } resourceCounters(): ResourceCounters { return { activeScanControllers: resourceCount(0), scanConsumers: resourceCount(0), chooserSessions: resourceCount(this.chooserBusy ? 1 : 0), connectionLeases: resourceCount(this.connectionsByPeer.size), physicalLinks: resourceCount( this.connectionsByPeer.size + [...this.retainedConnections].filter(record => record.device.gatt.connected).length + [...this.pendingConnectionsByPeer.values()].filter(pending => pending.device.gatt.connected).length ), databaseSnapshots: resourceCount( [...this.connectionsByPeer.values()].filter(record => record.database !== null).length ), physicalCccdEnablements: resourceCount(this.gattRuntime.subscriptionCount()), subscriptionConsumers: resourceCount(this.gattRuntime.subscriptionCount()), queuedOperations: resourceCount(this.pendingConnectionsByPeer.size), dispatchedOperations: resourceCount(0), retainedByteBuffers: resourceCount(this.gattRuntime.retainedSubscriptionCount()), restorationRecords: resourceCount(0), orphanedIpcOwners: resourceCount(0) } } destroy(): Promise { if (this.destroyResult === null) { this.destroyed = true const destruction = this.destroyAllResources() this.destroyResult = destruction.then( result => { if (result.state === 'release-failed') { this.destroyResult = null } return result }, error => { this.destroyResult = null throw error } ) } return this.destroyResult } async disconnectConnection(connection: WebBackendConnection): Promise { const active = this.connectionsByPeer.get(String(connection.peerId)) const retained = [...this.retainedConnections].find(record => record.connection === connection) const record = active !== undefined && active.connection === connection ? active : retained if (record === undefined) { return RELEASED } return this.disconnectRecord(record) } async disconnectRecord( record: WebConnectionRecord, reason: 'connection-lost' | 'owner-released' = 'owner-released' ): Promise { if (record.disconnectPromise !== null) return record.disconnectPromise const run = this.runDisconnectRecord(record, reason) record.disconnectPromise = run.then(result => { if (result.state !== 'released') record.disconnectPromise = null return result }) return record.disconnectPromise } private async runDisconnectRecord( record: WebConnectionRecord, reason: 'connection-lost' | 'owner-released' ): Promise { this.invalidateConnectionGenerations(record, reason) const phases: CleanupRecord[] = [] if (!record.subscriptionReleased) { const subscriptionCleanup = await this.gattRuntime.stopConnectionSubscriptions(record) if (subscriptionCleanup.state === 'released') record.subscriptionReleased = true phases.push(subscriptionCleanup) } if (!record.physicalReleased) { try { if (record.device.gatt.connected) { record.device.gatt.disconnect() } record.physicalReleased = true phases.push(RELEASED) } catch (error) { console.error('[WebBluetoothBackend.disconnectRecord] Browser disconnect failed:', error) this.retainedConnections.add(record) this.connectionsByPeer.delete(String(record.peerId)) phases.push(webCleanupFailure('connection', 'web-connection.disconnect')) } } const merged = mergeWebCleanupPhases(phases) if (record.subscriptionReleased && record.physicalReleased) { this.gattRuntime.invalidateConnection(record, reason) this.unbindConnectionRecord(record, reason) } return merged } staleGattError(operation: string): BackendContractError { return contractError('gatt.stale-handle', 'gatt', operation) } async readDirect( database: WebGattDatabase, path: CharacteristicPath, options: PublicOperationOptions ): Promise { this.assertAttached('web-gatt.database-read') return this.gattRuntime.readDirect(database, path, options) } async writeDirect( database: WebGattDatabase, path: CharacteristicPath, value: Readonly, options: WritePolicy ): Promise> { this.assertAttached('web-gatt.database-write') return this.gattRuntime.writeDirect(database, path, value, options) } async readDescriptorDirect( database: WebGattDatabase, path: DescriptorPath, options: PublicOperationOptions ): Promise { this.assertAttached('web-gatt.database-read-descriptor') return this.gattRuntime.readDescriptorDirect(database, path, options) } async writeDescriptorDirect( database: WebGattDatabase, path: DescriptorPath, value: Readonly, options: WritePolicy ): Promise> { this.assertAttached('web-gatt.database-write-descriptor') return this.gattRuntime.writeDescriptorDirect(database, path, value, options) } async subscribeDirect( database: WebGattDatabase, path: CharacteristicPath, options: SubscriptionOptions ): Promise> { this.assertAttached('web-gatt.database-subscribe') return this.gattRuntime.subscribeDirect(database, path, options) } private async currentAdapterState(): Promise> { this.assertUsable('web-adapter.current-state') const generation = this.beginAvailabilitySample() const available = await this.boundary.bluetoothAvailable() this.assertUsable('web-adapter.current-state') this.applyAvailabilitySample(generation, available) return this.attachmentRecord.adapter.state } private async assertBluetoothAvailable(operation: string): Promise { const generation = this.beginAvailabilitySample() const available = await this.boundary.bluetoothAvailable() this.applyAvailabilitySample(generation, available) if (this.attachmentRecord.adapter.state.availability !== 'available') { throw contractError('adapter.unavailable', 'adapter', operation) } } private beginAvailabilitySample(): number { this.availabilitySampleGeneration += 1 return this.availabilitySampleGeneration } private applyAvailabilitySample(generation: number, available: boolean): void { if (generation < this.appliedAvailabilitySampleGeneration) { return } this.appliedAvailabilitySampleGeneration = generation this.refreshAttachmentAvailability(available) } private refreshAttachmentAvailability(available: boolean): void { if (this.attachmentRecord.adapter.state.availability === (available ? 'available' : 'unavailable')) { return } this.attachmentGeneration += 1 this.attachmentRecord = this.createAttachmentRecord(available) for (const stream of this.adapterStreams) { const result = stream.emit( this.attachmentRecord.adapter.state, 96, String(this.attachmentRecord.backendGeneration) ) if (result.terminated) { this.adapterStreams.delete(stream) } } this.releaseAvailabilityObservationIfIdle() if (!available) { this.attached = false this.invalidateUnavailableSession() } } private invalidateUnavailableSession(): void { for (const pending of [...this.pendingConnectionsByPeer.values()]) { try { if (pending.device.gatt.connected) { pending.device.gatt.disconnect() } } catch (error) { console.error('[WebBluetoothBackend.invalidateUnavailableSession] Browser pending disconnect failed:', error) pending.state = 'cleanup-failed' pending.cleanupFailureReported = false continue } this.deletePendingConnectionIfOwned(pending) } for (const record of [...this.connectionsByPeer.values()]) { this.disconnectRecord(record, 'connection-lost').catch(() => undefined) } this.selectedDevices.clear() this.peerByBrowserDeviceId.clear() } private async releaseRetainedSessionResources(): Promise { const failures: CleanupFailure[] = [] for (const pending of [...this.pendingConnectionsByPeer.values()]) { await this.compensatePendingConnection(pending) if (this.pendingConnectionsByPeer.get(String(pending.peerId)) === pending) { failures.push(...webCleanupFailure('connection', 'web-connection.retained-compensation-failure').failures) } } for (const record of [...this.connectionsByPeer.values()]) { const cleanup = await this.disconnectRecord(record) failures.push(...cleanup.failures) } for (const record of [...this.retainedConnections]) { const cleanup = await this.disconnectRecord(record) failures.push(...cleanup.failures) } return failures.length === 0 ? RELEASED : { state: 'release-failed', failures } } private createAttachmentRecord(available: boolean): AttachmentRecord { const generation = opaqueId( `web-backend-generation-${this.backendInstance}-${this.attachmentGeneration}`, 'backend-generation', WEB_ATTACHMENT ) return { attachmentId: opaqueId( `web-attachment-${this.backendInstance}-${this.attachmentGeneration}`, 'attachment', WEB_ATTACHMENT ), backendInstanceId: opaqueId(`web-backend-${this.backendInstance}`, 'backend-instance', WEB_ATTACHMENT), backendGeneration: generation, adapter: webAdapterDescriptor(this.boundary, available, generation) } } private async watchAdapterState(): Promise> { this.assertUsable('web-adapter.watch-state') const initial = await this.currentAdapterState() this.assertUsable('web-adapter.watch-state') const transitions = new CoreBoundedStream>(DEFAULT_STREAM_LIMITS, 'latest') this.adapterStreams.add(transitions) try { this.ensureAvailabilityObservation() } catch (error) { this.adapterStreams.delete(transitions) throw error } return { initial, transitions: managedStream(transitions, () => { this.adapterStreams.delete(transitions) this.releaseAvailabilityObservationIfIdle() }) } } private ensureAvailabilityObservation(): void { if ( this.removeAvailabilityChangeListener !== null || this.availabilityPollTimer !== null || this.availabilityEventSilenceTimer !== null ) { return } const addAvailabilityChangeListener = this.boundary.addAvailabilityChangeListener if (addAvailabilityChangeListener !== undefined) { this.removeAvailabilityChangeListener = addAvailabilityChangeListener(() => { this.availabilityChangeObserved = true this.clearAvailabilityEventSilenceTimer() this.reconcileWatchedAvailability().then( () => undefined, error => { console.error( '[WebBluetoothBackend.reconcileWatchedAvailability] Availability change reconcile failed:', error ) } ) }) this.scheduleAvailabilityEventSilenceFallback() return } this.scheduleAvailabilityPoll() } private releaseAvailabilityObservationIfIdle(): void { if (this.adapterStreams.size > 0) { return } this.stopAvailabilityObservation() } private stopAvailabilityObservation(): void { if (this.removeAvailabilityChangeListener !== null) { this.removeAvailabilityChangeListener() this.removeAvailabilityChangeListener = null } this.clearAvailabilityEventSilenceTimer() if (this.availabilityPollTimer !== null) { this.boundary.clearTimer(this.availabilityPollTimer) this.availabilityPollTimer = null } this.availabilityChangeObserved = false } private scheduleAvailabilityEventSilenceFallback(): void { if ( this.destroyed || this.adapterStreams.size === 0 || this.availabilityEventSilenceTimer !== null || this.availabilityPollTimer !== null || this.availabilityChangeObserved ) { return } this.availabilityEventSilenceTimer = this.boundary.setTimer(() => { this.availabilityEventSilenceTimer = null if (!this.availabilityChangeObserved) { this.scheduleAvailabilityPoll() } }, WEB_ADAPTER_AVAILABILITY_POLL_INTERVAL_MS) } private clearAvailabilityEventSilenceTimer(): void { if (this.availabilityEventSilenceTimer === null) { return } this.boundary.clearTimer(this.availabilityEventSilenceTimer) this.availabilityEventSilenceTimer = null } private scheduleAvailabilityPoll(): void { if (this.destroyed || this.adapterStreams.size === 0 || this.availabilityPollTimer !== null) { return } this.availabilityPollTimer = this.boundary.setTimer(() => { this.availabilityPollTimer = null this.pollWatchedAvailability().then( () => undefined, error => { console.error('[WebBluetoothBackend.pollWatchedAvailability] Availability poll failed:', error) } ) }, WEB_ADAPTER_AVAILABILITY_POLL_INTERVAL_MS) } private async pollWatchedAvailability(): Promise { try { await this.reconcileWatchedAvailability() } finally { this.scheduleAvailabilityPoll() } } private async reconcileWatchedAvailability(): Promise { if (this.destroyed || this.adapterStreams.size === 0) { return } const generation = this.beginAvailabilitySample() let available: boolean try { available = await this.boundary.bluetoothAvailable() } catch (error) { console.error('[WebBluetoothBackend.reconcileWatchedAvailability] Browser availability probe failed:', error) return } if (this.destroyed || this.adapterStreams.size === 0) { return } this.applyAvailabilitySample(generation, available) } private async chooseDevice(request: ChooserRequest, operation: AbortableOperation): Promise { this.assertAttached('web-chooser.choose') this.assertAbortableAdmission(operation, 'chooser', 'web-chooser.choose') if (!this.boundary.isSecureContext()) { throw contractError('chooser.insecure-context', 'chooser', 'web-chooser.choose') } if (!this.boundary.hasTransientUserActivation()) { throw contractError('chooser.user-activation-required', 'chooser', 'web-chooser.choose') } if (this.chooserBusy) { throw contractError('chooser.busy', 'chooser', 'web-chooser.choose') } validateWebChooserRequest(request) const browserRequest = snapshotBrowserRequest(request) this.chooserBusy = true const browserSelection = Promise.resolve().then(async () => { await this.assertBluetoothAvailable('web-chooser.choose') return this.boundary.requestDevice(browserRequest) }) browserSelection.then( () => { this.chooserBusy = false }, () => { this.chooserBusy = false } ) const selected = await this.runAbortable( null, operation, () => browserSelection, 'chooser.cancelled', 'chooser', 'web-chooser.choose' ) this.assertUsable('web-chooser.choose') return this.rememberSelection(selected) } private rememberSelection(selection: WebBluetoothDeviceSelection): WebSelectedDevice { let peerId = this.peerByBrowserDeviceId.get(selection.device.id) if (peerId === undefined) { peerId = opaqueId(`web-device-${this.nextPeer}`, 'peer', WEB_ATTACHMENT) this.nextPeer += 1 this.peerByBrowserDeviceId.set(selection.device.id, peerId) } const previous = this.selectedDevices.get(String(peerId)) const grantedServices = new Set(previous?.grantedServices ?? []) for (const service of selection.grantedServices) grantedServices.add(String(service)) const selected: WebSelectedDevice = { peerId, device: selection.device, grantedServices } this.selectedDevices.set(String(peerId), selected) return selected } private async connect( peerId: PeerId, options: PublicOperationOptions ): Promise> { this.assertAttached('web-connection.connect') this.assertAbortableAdmission(options, 'connection', 'web-connection.connect') const peerKey = String(peerId) if (this.connectionsByPeer.has(peerKey) || this.pendingConnectionsByPeer.has(peerKey)) { throw contractError('connection.already-owned', 'connection', 'web-connection.connect') } const selected = this.selectedDevices.get(String(peerId)) if (selected === undefined) { throw contractError('connection.not-found', 'connection', 'web-connection.connect') } const pending: WebPendingConnection = { peerId, device: selected.device, grantedServices: selected.grantedServices, ownershipToken: {}, nativeConnect: Promise.resolve(), state: 'connecting', cleanupFailureReported: false } this.pendingConnectionsByPeer.set(peerKey, pending) pending.nativeConnect = Promise.resolve().then(async () => { await this.assertBluetoothAvailable('web-connection.connect') await selected.device.gatt.connect() }) pending.nativeConnect.then( () => undefined, error => { console.error('[WebBluetoothBackend.connect] Browser connect rejected:', error) this.deletePendingConnectionIfOwned(pending) } ) try { await this.runAbortable( null, options, () => pending.nativeConnect, 'connection.failed', 'connection', 'web-connection.connect', () => this.compensatePendingConnection(pending) ) } catch (error) { if ( error instanceof BackendContractError && error.normalized.code !== 'operation.aborted' && error.normalized.code !== 'operation.timed-out' && error.normalized.code !== 'operation.cancelled-by-destroy' ) { this.deletePendingConnectionIfOwned(pending) } throw error } if (this.pendingConnectionsByPeer.get(peerKey) !== pending || this.destroyed) { await this.compensatePendingConnection(pending) throw contractError('operation.cancelled-by-destroy', 'connection', 'web-connection.connect') } const connectionNumber = this.nextConnection this.nextConnection += 1 const connection = new WebBackendConnection( this, peerId, this.identifiers().connectionId(`web-connection-${connectionNumber}`), opaqueId( `web-connection-generation-${connectionNumber}`, 'connection-generation', `${WEB_ATTACHMENT}:${String(peerId)}` ) ) const leaseId = this.identifiers().leaseId(`web-connection-lease-${connectionNumber}`) let record: WebConnectionRecord | null = null const disconnectListener = () => { if (record !== null) { this.disconnectRecord(record, 'connection-lost').catch(() => undefined) } } record = { peerId, device: selected.device, grantedServices: selected.grantedServices, connection, leaseId, disconnectListener, disconnectWaiters: new Set(), database: null, valid: true, subscriptionReleased: false, physicalReleased: false, disconnectPromise: null } selected.device.addDisconnectListener(disconnectListener) this.deletePendingConnectionIfOwned(pending) this.connectionsByPeer.set(peerKey, record) return new WebConnectionLease(this, record, leaseId) } private async compensatePendingConnection(pending: WebPendingConnection): Promise { if (pending.state === 'compensating') { return } pending.state = 'compensating' try { if (pending.device.gatt.connected) { pending.device.gatt.disconnect() } this.deletePendingConnectionIfOwned(pending) } catch (error) { pending.state = 'cleanup-failed' pending.cleanupFailureReported = false console.error('[WebBluetoothBackend.compensatePendingConnection] Browser disconnect failed:', error) } } private deletePendingConnectionIfOwned(pending: WebPendingConnection): void { const peerKey = String(pending.peerId) if (this.pendingConnectionsByPeer.get(peerKey) === pending) { this.pendingConnectionsByPeer.delete(peerKey) } } private invalidateConnectionGenerations( record: WebConnectionRecord, reason: 'connection-lost' | 'owner-released' ): void { if (!record.valid) { return } record.valid = false record.connection.transition(reason === 'connection-lost' ? 'lost' : 'disconnected') record.database?.invalidate() for (const waiter of [...record.disconnectWaiters]) { waiter() } record.disconnectWaiters.clear() if (reason === 'connection-lost') { this.emitBackendEvent({ attachment: this.attachmentRecord, attachmentId: this.attachmentRecord.attachmentId, ingressOrdinal: this.ingressOrdinal, kind: 'connection-lost', connection: { attachment: this.attachmentRecord, attachmentId: this.attachmentRecord.attachmentId, peerId: record.peerId, connectionId: record.connection.connectionId, ownerLeaseId: record.leaseId, connectionGeneration: record.connection.connectionGeneration } }) this.ingressOrdinal += 1 } } private unbindConnectionRecord(record: WebConnectionRecord, _reason: 'connection-lost' | 'owner-released'): void { record.device.removeDisconnectListener(record.disconnectListener) this.connectionsByPeer.delete(String(record.peerId)) this.retainedConnections.delete(record) } requireConnection(connection: BackendConnection, operation: string): WebConnectionRecord { this.assertAttached(operation) const record = this.connectionsByPeer.get(String(connection.peerId)) if (record === undefined || record.connection !== connection || !record.valid) { throw contractError('connection.stale', 'connection', operation) } return record } requireDatabase( path: | CharacteristicPath | DescriptorPath, operation: string ): WebGattDatabase { this.assertAttached(operation) const record = this.connectionsByPeer.get(String(path.peerId)) const database = record?.database if ( record === undefined || database === null || database === undefined || !record.valid || path.validity !== 'current' || !attachmentRecordsEqual(path.attachment, this.attachmentRecord) || path.attachmentId !== this.attachmentRecord.attachmentId || record.peerId !== path.peerId || record.connection.connectionId !== path.connectionId || record.leaseId !== path.ownerLeaseId || record.connection.connectionGeneration !== path.connectionGeneration || !attachmentRecordsEqual(database.path.attachment, path.attachment) || database.path.attachmentId !== path.attachmentId || database.path.peerId !== path.peerId || database.path.connectionId !== path.connectionId || database.path.ownerLeaseId !== path.ownerLeaseId || database.path.databaseId !== path.databaseId || database.path.databaseGeneration !== path.databaseGeneration || database.path.connectionGeneration !== path.connectionGeneration ) { throw this.staleGattError(operation) } database.assertCurrent(operation) return database } async runAbortable( record: WebConnectionRecord | null, operation: AbortableOperation, start: () => Promise, fallbackCode: BleErrorCode, domain: 'chooser' | 'connection' | 'gatt', operationName: string, onLateSuccess: ((result: Result) => Promise | void) | null = null ): Promise { this.assertAbortableAdmission(operation, domain, operationName) return new Promise((resolve, reject) => { let settled = false let timer: WebBluetoothTimerHandle | null = null const cleanup = () => { operation.signal?.removeEventListener('abort', abort) if (timer !== null) { this.boundary.clearTimer(timer) } record?.disconnectWaiters.delete(disconnected) this.destroyWaiters.delete(destroyed) } const settleFailure = (error: BackendContractError) => { if (settled) { return } settled = true cleanup() reject(error) } const abort = () => { settleFailure(contractError('operation.aborted', domain, operationName)) } const disconnected = () => { settleFailure(contractError('operation.disconnected', domain, operationName)) } const destroyed = () => { settleFailure(contractError('operation.cancelled-by-destroy', domain, operationName)) } operation.signal?.addEventListener('abort', abort, { once: true }) record?.disconnectWaiters.add(disconnected) this.destroyWaiters.add(destroyed) if (operation.deadline !== null) { timer = this.boundary.setTimer( () => settleFailure(contractError('operation.timed-out', domain, operationName)), Math.max(0, Number(operation.deadline) - this.boundary.now()) ) } Promise.resolve() .then(start) .then( value => { if (settled) { if (onLateSuccess !== null) { Promise.resolve(onLateSuccess(value)).then( () => undefined, error => { console.error(`[WebBluetoothBackend.runAbortable] ${operationName} late cleanup failed:`, error) } ) } return } if (record !== null && !record.valid) { disconnected() return } settled = true cleanup() resolve(value) }, error => { if (settled) { return } const normalized = error instanceof Error ? normalizeWebBluetoothError(error, { fallbackCode, domain, operation: operationName }) : contractError(fallbackCode, domain, operationName) settleFailure(normalized) } ) }) } private assertAbortableAdmission( operation: AbortableOperation, domain: 'chooser' | 'connection' | 'gatt', operationName: string ): void { this.assertUsable(operationName) if (operation.signal?.aborted === true) { throw contractError('operation.aborted', domain, operationName) } if (operation.deadline !== null && this.boundary.now() >= operation.deadline) { throw contractError('operation.timed-out', domain, operationName) } } private cancelInFlightOperations(): void { for (const waiter of [...this.destroyWaiters]) { waiter() } } private emitBackendEvent(event: BackendEvent): void { for (const stream of this.eventStreams) { const result = stream.emit(event, 1) if (result.terminated) { this.eventStreams.delete(stream) } } } identifiers() { const binding: AttachmentBinding = { attachmentId: this.attachmentRecord.attachmentId, backendInstanceId: this.attachmentRecord.backendInstanceId, backendGeneration: this.attachmentRecord.backendGeneration, adapterId: this.attachmentRecord.adapter.adapterId, adapterGeneration: this.attachmentRecord.adapter.adapterGeneration } return createAttachmentBoundIdFactory(binding) } private assertAttached(operation: string): void { this.assertUsable(operation) if (!this.attached) { throw contractError('lifecycle.invalid-state', 'core', operation) } } private assertUsable(operation: string): void { if (this.destroyed) { throw contractError('lifecycle.destroyed', 'core', operation) } } private async destroyAllResources(): Promise { const failures: CleanupFailure[] = [] this.cancelInFlightOperations() for (const pending of [...this.pendingConnectionsByPeer.values()]) { if (pending.state === 'cleanup-failed') { if (!pending.cleanupFailureReported) { pending.cleanupFailureReported = true failures.push(...webCleanupFailure('connection', 'web-connection.retained-compensation-failure').failures) continue } await this.compensatePendingConnection(pending) } if (pending.device.gatt.connected) { await this.compensatePendingConnection(pending) } } this.stopAvailabilityObservation() if (this.removePageLifecycleListener !== null) { this.removePageLifecycleListener() this.removePageLifecycleListener = null } for (const record of [...this.connectionsByPeer.values()]) { const cleanup = await this.disconnectRecord(record) failures.push(...cleanup.failures) } for (const record of [...this.retainedConnections]) { const cleanup = await this.disconnectRecord(record) failures.push(...cleanup.failures) } failures.push(...(await this.gattRuntime.destroySubscriptions())) for (const stream of this.eventStreams) { await stream.close() } this.eventStreams.clear() for (const stream of this.adapterStreams) { await stream.close() } this.adapterStreams.clear() this.selectedDevices.clear() this.peerByBrowserDeviceId.clear() if (this.chooserBusy) { failures.push(...webCleanupFailure('chooser', 'web-chooser.pending-destroy').failures) } for (const pending of this.pendingConnectionsByPeer.values()) { failures.push( ...webCleanupFailure( 'connection', pending.state === 'cleanup-failed' ? 'web-connection.compensation-failed' : 'web-connection.pending-destroy' ).failures ) } return failures.length === 0 ? RELEASED : { state: 'release-failed', failures } } } function webAdapterDescriptor( boundary: WebBluetoothBoundary, available: boolean, generation: GenerationId<'backend-generation', string> = opaqueId( 'web-adapter-generation', 'backend-generation', WEB_ATTACHMENT ) ): AdapterDescriptor { return { adapterId: WEB_ADAPTER_ID, displayName: 'Web Bluetooth', state: { availability: available ? 'available' : 'unavailable', authorization: available ? 'not-determined' : 'unavailable', power: available ? 'unknown' : 'unsupported', backendGeneration: generation, updatedAt: monotonicTimestamp(boundary.now()), safeReason: available ? null : 'Web Bluetooth is unavailable in this browser context.' }, adapterGeneration: opaqueId(String(generation), 'adapter-generation', WEB_ATTACHMENT), limitations: [ 'chooser-based discovery only; continuous passive scanning is unavailable', 'background execution and process-level restoration are unavailable' ] } } export const WEB_BLUETOOTH_ADAPTER_ID = WEB_ADAPTER_ID function snapshotBrowserRequest(request: ChooserRequest): WebBluetoothRequestDeviceOptions { return { filters: request.filters.map(snapshotBrowserFilter), acceptAllDevices: request.acceptAllDevices, optionalServices: [...request.optionalServices] } } function snapshotBrowserFilter(filter: ScanFilter): WebBluetoothRequestFilter { return { services: [...filter.serviceUuids], manufacturerData: filter.manufacturerData.map(manufacturer => ({ companyIdentifier: manufacturer.companyIdentifier, dataPrefix: manufacturer.dataPrefix === null ? null : new Uint8Array(manufacturer.dataPrefix) })), namePrefix: filter.localNamePrefix } } function managedStream(stream: CoreBoundedStream, unregister: () => void): BoundedAsyncStream { return { limits: stream.limits, overflowPolicy: stream.overflowPolicy, [Symbol.asyncIterator]: () => stream[Symbol.asyncIterator](), close: async () => { const cleanup = await stream.close() unregister() return cleanup } } }