// src/web/navigator-web-bluetooth-boundary.ts import type { Uuid } from '../backend-contract/primitives' import type { WebBluetoothAvailabilityListener, WebBluetoothBoundary, WebBluetoothCharacteristicBoundary, WebBluetoothDescriptorBoundary, WebBluetoothDeviceBoundary, WebBluetoothDeviceSelection, WebBluetoothDisconnectListener, WebBluetoothGattServerBoundary, WebBluetoothNotificationListener, WebBluetoothPageLifecycleReason, WebBluetoothRequestDeviceOptions, WebBluetoothRequestFilter, WebBluetoothServiceBoundary, WebBluetoothTimerHandle } from './web-bluetooth-boundary' interface BrowserValueView { readonly buffer: ArrayBufferLike readonly byteOffset: number readonly byteLength: number } interface BrowserBluetoothDescriptor { readonly uuid: string readValue(): Promise writeValue(value: ArrayBuffer): Promise } interface BrowserBluetoothCharacteristicProperties { readonly read?: boolean readonly write?: boolean readonly writeWithoutResponse?: boolean readonly notify?: boolean readonly indicate?: boolean } interface BrowserBluetoothNotificationEvent { readonly target: BrowserBluetoothCharacteristic } interface BrowserBluetoothCharacteristic { readonly uuid: string readonly properties: BrowserBluetoothCharacteristicProperties readonly value?: BrowserValueView | null getDescriptors(): Promise readValue(): Promise writeValueWithResponse(value: ArrayBuffer): Promise writeValueWithoutResponse(value: ArrayBuffer): Promise startNotifications(): Promise stopNotifications(): Promise addEventListener( type: 'characteristicvaluechanged', listener: (event: BrowserBluetoothNotificationEvent) => void ): void removeEventListener( type: 'characteristicvaluechanged', listener: (event: BrowserBluetoothNotificationEvent) => void ): void } interface BrowserBluetoothService { readonly uuid: string getCharacteristics(): Promise } interface BrowserBluetoothGattServer { readonly connected: boolean connect(): Promise disconnect(): void getPrimaryServices(): Promise } interface BrowserBluetoothDevice { readonly id: string readonly gatt?: BrowserBluetoothGattServer | null addEventListener(type: 'gattserverdisconnected', listener: () => void): void removeEventListener(type: 'gattserverdisconnected', listener: () => void): void } type BrowserBluetoothRequestOptions = | { readonly acceptAllDevices: true readonly optionalServices?: string[] } | { readonly filters: Array<{ readonly services?: string[] readonly manufacturerData?: Array<{ readonly companyIdentifier: number readonly dataPrefix?: Uint8Array }> readonly namePrefix?: string }> readonly optionalServices?: string[] } interface BrowserBluetoothAvailabilityChangedEvent { readonly value?: boolean } type BrowserBluetoothAvailabilityListener = (event: BrowserBluetoothAvailabilityChangedEvent) => void interface BrowserBluetooth { getAvailability?(): Promise getDevices?(): Promise requestDevice(options?: BrowserBluetoothRequestOptions): Promise addEventListener?(type: 'availabilitychanged', listener: BrowserBluetoothAvailabilityListener): void removeEventListener?(type: 'availabilitychanged', listener: BrowserBluetoothAvailabilityListener): void // Match spec Bluetooth: the property is an Event listener. Feature-detect // with `'onavailabilitychanged' in bluetooth`, not this callable type. onavailabilitychanged?: ((event: Event) => unknown) | null } type BrowserBluetoothAvailabilityEventTarget = BrowserBluetooth & { addEventListener(type: 'availabilitychanged', listener: BrowserBluetoothAvailabilityListener): void removeEventListener(type: 'availabilitychanged', listener: BrowserBluetoothAvailabilityListener): void } export function createDefaultNavigatorWebBluetoothEnvironment(): NavigatorWebBluetoothEnvironment { const globalObject = globalThis as typeof globalThis & { readonly navigator?: { readonly bluetooth?: BrowserBluetooth readonly userAgent?: string readonly userActivation?: { readonly isActive?: boolean } } readonly isSecureContext?: boolean readonly document?: { readonly visibilityState?: string addEventListener(type: string, listener: () => void): void removeEventListener(type: string, listener: () => void): void } readonly window?: { addEventListener(type: string, listener: () => void): void removeEventListener(type: string, listener: () => void): void } } return { implementationVersion: 'unified-ble-manager-web', browserEngine: globalObject.navigator?.userAgent ?? 'unknown', bluetooth: globalObject.navigator?.bluetooth ?? null, isSecureContext: () => globalObject.isSecureContext === true, hasTransientUserActivation: () => globalObject.navigator?.userActivation?.isActive === true, now: () => performance.now(), setTimer: (callback, delayMilliseconds) => { const handle = { id: globalThis.setTimeout(callback, delayMilliseconds) } return handle }, clearTimer: handle => { globalThis.clearTimeout((handle as { readonly id: ReturnType }).id) }, addPageLifecycleListener: listener => { const onHidden = () => { if (globalObject.document?.visibilityState === 'hidden') { listener('page-hidden') } } const onPageHide = () => listener('page-unloaded') globalObject.document?.addEventListener('visibilitychange', onHidden) globalObject.window?.addEventListener('pagehide', onPageHide) return () => { globalObject.document?.removeEventListener('visibilitychange', onHidden) globalObject.window?.removeEventListener('pagehide', onPageHide) } } } } export interface NavigatorWebBluetoothEnvironment { readonly implementationVersion: string readonly browserEngine: string readonly bluetooth: BrowserBluetooth | null isSecureContext(): boolean hasTransientUserActivation(): boolean now(): number setTimer(callback: () => void, delayMilliseconds: number): WebBluetoothTimerHandle clearTimer(handle: WebBluetoothTimerHandle): void addPageLifecycleListener(listener: (reason: WebBluetoothPageLifecycleReason) => void): () => void } /** Concrete Web Bluetooth adapter over explicitly supplied browser APIs. */ export class NavigatorWebBluetoothBoundary implements WebBluetoothBoundary { readonly implementationVersion: string readonly browserEngine: string readonly getAuthorizedDevices: (() => Promise) | undefined readonly addAvailabilityChangeListener: ((listener: WebBluetoothAvailabilityListener) => () => void) | undefined constructor(private readonly environment: NavigatorWebBluetoothEnvironment) { this.implementationVersion = environment.implementationVersion this.browserEngine = environment.browserEngine const bluetooth = environment.bluetooth this.getAuthorizedDevices = bluetooth?.getDevices === undefined ? undefined : async () => { if (bluetooth === null || bluetooth.getDevices === undefined) { throw new Error('Web Bluetooth authorized-device enumeration is no longer available') } const devices = await bluetooth.getDevices() return devices.map(device => new NavigatorDeviceBoundary(device)) } if (browserBluetoothSupportsAvailabilityEvents(bluetooth)) { this.addAvailabilityChangeListener = listener => subscribeBrowserAvailabilityChanged(bluetooth, listener) } else { this.addAvailabilityChangeListener = undefined } } isSecureContext(): boolean { return this.environment.isSecureContext() } hasTransientUserActivation(): boolean { return this.environment.hasTransientUserActivation() } async bluetoothAvailable(): Promise { const bluetooth = this.environment.bluetooth if (bluetooth === null) { return false } return bluetooth.getAvailability === undefined ? true : bluetooth.getAvailability() } async requestDevice(options: WebBluetoothRequestDeviceOptions): Promise { const bluetooth = this.requireBluetooth() const grantedServices = requestedServices(options) const request: BrowserBluetoothRequestOptions = options.acceptAllDevices ? { acceptAllDevices: true, optionalServices: [...options.optionalServices] } : { filters: options.filters.map(browserRequestFilter), optionalServices: [...options.optionalServices] } const device = await bluetooth.requestDevice(request) return { device: new NavigatorDeviceBoundary(device), grantedServices } } now(): number { return this.environment.now() } setTimer(callback: () => void, delayMilliseconds: number): WebBluetoothTimerHandle { return this.environment.setTimer(callback, delayMilliseconds) } clearTimer(handle: WebBluetoothTimerHandle): void { this.environment.clearTimer(handle) } addPageLifecycleListener(listener: (reason: WebBluetoothPageLifecycleReason) => void): () => void { return this.environment.addPageLifecycleListener(listener) } private requireBluetooth(): BrowserBluetooth { if (this.environment.bluetooth === null) { const error = new Error('Web Bluetooth API is unavailable') error.name = 'NotSupportedError' throw error } return this.environment.bluetooth } } class NavigatorDeviceBoundary implements WebBluetoothDeviceBoundary { readonly id: string readonly gatt: WebBluetoothGattServerBoundary private readonly disconnectListeners = new Map void>() constructor(private readonly device: BrowserBluetoothDevice) { this.id = device.id if (device.gatt === undefined || device.gatt === null) { const error = new Error('Selected device does not expose a GATT server') error.name = 'NotSupportedError' throw error } this.gatt = new NavigatorGattServerBoundary(device.gatt) } addDisconnectListener(listener: WebBluetoothDisconnectListener): void { const browserListener = () => listener() this.disconnectListeners.set(listener, browserListener) this.device.addEventListener('gattserverdisconnected', browserListener) } removeDisconnectListener(listener: WebBluetoothDisconnectListener): void { const browserListener = this.disconnectListeners.get(listener) if (browserListener === undefined) { return } this.disconnectListeners.delete(listener) this.device.removeEventListener('gattserverdisconnected', browserListener) } } class NavigatorGattServerBoundary implements WebBluetoothGattServerBoundary { constructor(private readonly server: BrowserBluetoothGattServer) {} get connected(): boolean { return this.server.connected } async connect(): Promise { await this.server.connect() } disconnect(): void { this.server.disconnect() } async getPrimaryServices(): Promise { const services = await this.server.getPrimaryServices() return services.map(service => new NavigatorServiceBoundary(service)) } } class NavigatorServiceBoundary implements WebBluetoothServiceBoundary { readonly uuid: string constructor(private readonly service: BrowserBluetoothService) { this.uuid = service.uuid } async getCharacteristics(): Promise { const characteristics = await this.service.getCharacteristics() return characteristics.map(characteristic => new NavigatorCharacteristicBoundary(characteristic)) } } class NavigatorCharacteristicBoundary implements WebBluetoothCharacteristicBoundary { readonly uuid: string readonly properties private readonly notificationListeners = new Map< WebBluetoothNotificationListener, (event: BrowserBluetoothNotificationEvent) => void >() constructor(private readonly characteristic: BrowserBluetoothCharacteristic) { this.uuid = characteristic.uuid this.properties = { read: characteristic.properties.read === true, write: characteristic.properties.write === true, writeWithoutResponse: characteristic.properties.writeWithoutResponse === true, notify: characteristic.properties.notify === true, indicate: characteristic.properties.indicate === true } } async getDescriptors(): Promise { const descriptors = await this.characteristic.getDescriptors() return descriptors.map(descriptor => new NavigatorDescriptorBoundary(descriptor)) } async readValue(): Promise { return copyView(await this.characteristic.readValue()) } async writeValueWithResponse(value: Uint8Array): Promise { await this.characteristic.writeValueWithResponse(copyToArrayBuffer(value)) } async writeValueWithoutResponse(value: Uint8Array): Promise { await this.characteristic.writeValueWithoutResponse(copyToArrayBuffer(value)) } async startNotifications(): Promise { await this.characteristic.startNotifications() } async stopNotifications(): Promise { await this.characteristic.stopNotifications() } addNotificationListener(listener: WebBluetoothNotificationListener): void { const browserListener = (event: BrowserBluetoothNotificationEvent) => { const value = event.target.value if (value !== undefined && value !== null) { listener(copyView(value)) } } this.notificationListeners.set(listener, browserListener) this.characteristic.addEventListener('characteristicvaluechanged', browserListener) } removeNotificationListener(listener: WebBluetoothNotificationListener): void { const browserListener = this.notificationListeners.get(listener) if (browserListener === undefined) { return } this.notificationListeners.delete(listener) this.characteristic.removeEventListener('characteristicvaluechanged', browserListener) } } class NavigatorDescriptorBoundary implements WebBluetoothDescriptorBoundary { readonly uuid: string constructor(private readonly descriptor: BrowserBluetoothDescriptor) { this.uuid = descriptor.uuid } async readValue(): Promise { return copyView(await this.descriptor.readValue()) } async writeValue(value: Uint8Array): Promise { await this.descriptor.writeValue(copyToArrayBuffer(value)) } } function browserBluetoothSupportsAvailabilityEvents( bluetooth: BrowserBluetooth | null ): bluetooth is BrowserBluetoothAvailabilityEventTarget { // Spec Bluetooth is always an EventTarget. The documented capability check is // `'onavailabilitychanged' in bluetooth`, not addEventListener presence. return ( bluetooth !== null && typeof bluetooth.addEventListener === 'function' && typeof bluetooth.removeEventListener === 'function' && 'onavailabilitychanged' in bluetooth ) } function subscribeBrowserAvailabilityChanged( bluetooth: BrowserBluetoothAvailabilityEventTarget, listener: WebBluetoothAvailabilityListener ): () => void { const browserListener: BrowserBluetoothAvailabilityListener = () => { // Ignore event.value; the backend re-samples bluetoothAvailable(). listener() } bluetooth.addEventListener('availabilitychanged', browserListener) return () => { bluetooth.removeEventListener('availabilitychanged', browserListener) } } function requestedServices(options: WebBluetoothRequestDeviceOptions): readonly Uuid[] { return [...new Set([...options.filters.flatMap(filter => filter.services), ...options.optionalServices])] } function browserRequestFilter(filter: WebBluetoothRequestFilter): { readonly services?: string[] readonly manufacturerData?: Array<{ readonly companyIdentifier: number readonly dataPrefix?: Uint8Array }> readonly namePrefix?: string } { return { services: filter.services.length === 0 ? undefined : [...filter.services], manufacturerData: filter.manufacturerData.length === 0 ? undefined : filter.manufacturerData.map(manufacturer => ({ companyIdentifier: manufacturer.companyIdentifier, dataPrefix: manufacturer.dataPrefix === null ? undefined : new Uint8Array(manufacturer.dataPrefix) })), namePrefix: filter.namePrefix === null ? undefined : filter.namePrefix } } function copyView(view: BrowserValueView): Uint8Array { return new Uint8Array(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)) } function copyToArrayBuffer(value: Uint8Array): ArrayBuffer { const copy = new Uint8Array(value.byteLength) copy.set(value) return copy.buffer }