From a41875f7a3cf008b605eea31101308ec4e1e78f5 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 7 Oct 2026 21:52:37 +0100 Subject: [PATCH 01/16] feat(web): run Switchify Remote in the browser over Web Bluetooth - Add WebBluetoothTransport behind the existing BleTransport interface, chosen by a platform factory so the web bundle never loads react-native-ble-plx - Serialize GATT operations, cap frames at 182 bytes (Web Bluetooth hides the MTU and macOS rejects long writes), support notify and read-v1 replies, and reuse devices the browser remembers for saved PCs - Treat a closed device picker as a cancelled selection that returns to idle - Store pairing secrets on web with a non-extractable AES-GCM key in IndexedDB - Route alerts, screen-reader announcements and focus moves to the DOM on web, where React Native Web leaves them as no-ops - Download diagnostics on web and use browser-specific Bluetooth guidance Co-Authored-By: Claude Opus 5.5 --- src/app/(tabs)/index.tsx | 4 +- src/app/_layout.tsx | 3 + src/app/diagnostics.tsx | 10 +- src/components/accessibilityFocus.web.ts | 8 + .../useAccessibilityAnnouncement.test.ts | 4 +- .../useAccessibilityAnnouncement.ts | 2 +- src/connection/ConnectionContext.tsx | 4 +- src/connection/ConnectionManager.ts | 32 +- src/connection/connection.test.ts | 31 +- src/connection/permissions.test.ts | 20 +- src/connection/permissions.ts | 13 + src/diagnostics/DiagnosticLog.ts | 1 + src/diagnostics/exportDiagnostics.ts | 9 + src/diagnostics/exportDiagnostics.web.ts | 14 + src/onboarding/FirstRunSetup.tsx | 6 +- src/platform/installPlatform.ts | 1 + src/platform/installPlatform.web.ts | 85 ++++ src/storage/PairingStore.ts | 5 +- src/storage/secretStorage.ts | 5 + src/storage/secretStorage.web.ts | 97 ++++ src/theme/ThemeContext.tsx | 6 +- src/theme/reducedTransparency.ts | 7 + src/theme/reducedTransparency.web.ts | 8 + src/transport/BleTransport.ts | 5 + src/transport/WebBluetoothTransport.test.ts | 247 ++++++++++ src/transport/WebBluetoothTransport.ts | 428 ++++++++++++++++++ src/transport/createBleTransport.ts | 7 + src/transport/createBleTransport.web.ts | 7 + 28 files changed, 1034 insertions(+), 35 deletions(-) create mode 100644 src/components/accessibilityFocus.web.ts create mode 100644 src/diagnostics/exportDiagnostics.ts create mode 100644 src/diagnostics/exportDiagnostics.web.ts create mode 100644 src/platform/installPlatform.ts create mode 100644 src/platform/installPlatform.web.ts create mode 100644 src/storage/secretStorage.ts create mode 100644 src/storage/secretStorage.web.ts create mode 100644 src/theme/reducedTransparency.ts create mode 100644 src/theme/reducedTransparency.web.ts create mode 100644 src/transport/WebBluetoothTransport.test.ts create mode 100644 src/transport/WebBluetoothTransport.ts create mode 100644 src/transport/createBleTransport.ts create mode 100644 src/transport/createBleTransport.web.ts diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index f1e9190..febe855 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -11,7 +11,7 @@ import { StatusBadge } from '@/components/StatusBadge'; import { UnpairButton } from '@/components/UnpairButton'; import { useAccessibilityAnnouncement } from '@/components/useAccessibilityAnnouncement'; import { useConnectionManager, useConnectionState } from '@/connection/ConnectionContext'; -import { bluetoothPermissionRecoveryMessage } from '@/connection/permissions'; +import { bluetoothPermissionRecoveryMessage, bluetoothUnsupportedMessage } from '@/connection/permissions'; import { mergePcList, pcListAction, type PcListItem } from '@/connection/pcList'; import { shouldUseTwoColumns, useLayout, useTheme } from '@/theme/ThemeContext'; @@ -36,7 +36,7 @@ export default function PcsScreen() { {state.kind !== 'connected' && state.kind !== 'pairing' && state.kind !== 'connecting' && state.kind !== 'reconnecting' ? void manager.scan()} /> : null} {state.kind === 'permissionDenied' ? void Linking.openSettings()} />} /> : null} {state.kind === 'bluetoothOff' ? : null} - {state.kind === 'unsupported' ? : null} + {state.kind === 'unsupported' ? : null} {state.kind === 'scanning' && discovered.length === 0 ? : null} {pcs.length > 0 ? PCs{pcs.map((pc, index) => { if (pc.saved) void manager.connectSaved({ ...pc.saved, displayName: pc.displayName, platform: pc.platform, peripheralId: pc.peripheralId }); else void manager.connect(pc); }} unpair={pc.saved ? () => void manager.unpair(pc.desktopId) : null} />)} : null} ; diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 5f3e5c1..3bcc34d 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -7,8 +7,11 @@ import { ConnectionProvider } from '@/connection/ConnectionContext'; import { diagnosticsScreenOptions } from '@/navigation/diagnosticsScreenOptions'; import { FirstRunSetupGate } from '@/onboarding/FirstRunSetup'; import { StoreCaptureRoot, storeCaptureEnabled } from '@/store-capture/StoreCaptureRoot'; +import { installPlatform } from '@/platform/installPlatform'; import { ThemeProvider, useTheme } from '@/theme/ThemeContext'; +installPlatform(); + export default function RootLayout() { return ( diff --git a/src/app/diagnostics.tsx b/src/app/diagnostics.tsx index 077ddbc..a25874b 100644 --- a/src/app/diagnostics.tsx +++ b/src/app/diagnostics.tsx @@ -1,6 +1,4 @@ import * as Clipboard from 'expo-clipboard'; -import * as FileSystem from 'expo-file-system/legacy'; -import * as Sharing from 'expo-sharing'; import { useSyncExternalStore } from 'react'; import { View } from 'react-native'; @@ -11,6 +9,7 @@ import { EmptyState } from '@/components/EmptyState'; import { Screen } from '@/components/Screen'; import { ResponsiveGrid } from '@/components/ResponsiveGrid'; import { useConnectionManager } from '@/connection/ConnectionContext'; +import { exportDiagnostics } from '@/diagnostics/exportDiagnostics'; import { useTheme } from '@/theme/ThemeContext'; export default function DiagnosticsScreen() { @@ -29,10 +28,3 @@ export default function DiagnosticsScreen() { ); } - -async function exportDiagnostics(contents: string): Promise { - if (!FileSystem.cacheDirectory || !await Sharing.isAvailableAsync()) return; - const path = `${FileSystem.cacheDirectory}switchify-remote-diagnostics.txt`; - await FileSystem.writeAsStringAsync(path, contents); - await Sharing.shareAsync(path, { mimeType: 'text/plain', dialogTitle: 'Export Switchify Remote diagnostics' }); -} diff --git a/src/components/accessibilityFocus.web.ts b/src/components/accessibilityFocus.web.ts new file mode 100644 index 0000000..6aa9f4c --- /dev/null +++ b/src/components/accessibilityFocus.web.ts @@ -0,0 +1,8 @@ +import type { View } from 'react-native'; + +export function focusAccessibilityTarget(target: View | null): void { + const element = target as unknown as HTMLElement | null; + if (!element || typeof element.focus !== 'function') return; + if (!element.hasAttribute('tabindex')) element.setAttribute('tabindex', '-1'); + element.focus({ preventScroll: false }); +} diff --git a/src/components/useAccessibilityAnnouncement.test.ts b/src/components/useAccessibilityAnnouncement.test.ts index 054a17e..b659480 100644 --- a/src/components/useAccessibilityAnnouncement.test.ts +++ b/src/components/useAccessibilityAnnouncement.test.ts @@ -1,7 +1,7 @@ import { announceAccessibilityTransition } from './useAccessibilityAnnouncement'; describe('accessibility announcements', () => { - it.each(['ios', 'android'])('announces each %s transition once', (platform) => { + it.each(['ios', 'android', 'web'])('announces each %s transition once', (platform) => { const announce = jest.fn(); let previous: string | null = null; previous = announceAccessibilityTransition(platform, 'Connecting.', previous, announce); @@ -14,7 +14,7 @@ describe('accessibility announcements', () => { it('does not announce on unsupported platforms', () => { const announce = jest.fn(); - announceAccessibilityTransition('web', 'Connected.', null, announce); + announceAccessibilityTransition('windows', 'Connected.', null, announce); expect(announce).not.toHaveBeenCalled(); }); }); diff --git a/src/components/useAccessibilityAnnouncement.ts b/src/components/useAccessibilityAnnouncement.ts index 5733336..e334567 100644 --- a/src/components/useAccessibilityAnnouncement.ts +++ b/src/components/useAccessibilityAnnouncement.ts @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import { AccessibilityInfo, Platform } from 'react-native'; export function announceAccessibilityTransition(platform: string, message: string | null, previous: string | null, announce: (value: string) => void): string | null { - if ((platform === 'ios' || platform === 'android') && message && message !== previous) announce(message); + if ((platform === 'ios' || platform === 'android' || platform === 'web') && message && message !== previous) announce(message); return message; } diff --git a/src/connection/ConnectionContext.tsx b/src/connection/ConnectionContext.tsx index 87b1845..d522610 100644 --- a/src/connection/ConnectionContext.tsx +++ b/src/connection/ConnectionContext.tsx @@ -5,7 +5,7 @@ import { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; import { PairingStore } from '@/storage/PairingStore'; import { preferencesStore } from '@/storage/PreferencesStore'; import { resolveRemoteName } from '@/device/remoteName'; -import { ReactNativeBleTransport } from '@/transport/ReactNativeBleTransport'; +import { createBleTransport } from '@/transport/createBleTransport'; import { ConnectionManager, type ConnectionState } from './ConnectionManager'; import { requestBluetoothPermission } from './permissions'; @@ -15,7 +15,7 @@ export function ConnectionProvider({ children }: PropsWithChildren) { const manager = useMemo(() => { const diagnostics = new DiagnosticLog(); return new ConnectionManager( - new ReactNativeBleTransport(null, undefined, undefined, undefined, diagnostics), + createBleTransport(diagnostics), new PairingStore(), diagnostics, requestBluetoothPermission, diff --git a/src/connection/ConnectionManager.ts b/src/connection/ConnectionManager.ts index 679c9b3..646b6ef 100644 --- a/src/connection/ConnectionManager.ts +++ b/src/connection/ConnectionManager.ts @@ -4,7 +4,7 @@ import { authenticatedCommand, commandPayloads, pairingRequest } from '@/domain/ import type { JsonObject, PointerProfile, ProtocolResponse } from '@/domain/protocol/types'; import { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; import type { PairingStorage, SavedPc } from '@/storage/PairingStore'; -import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from '@/transport/BleTransport'; +import { BluetoothDeviceSelectionCancelledError, type BleAvailability, type BleTransport, type DiscoveredDesktop, type Unsubscribe } from '@/transport/BleTransport'; import { ProtocolClient, ProtocolWriteError } from './ProtocolClient'; import { pairingVerificationCode } from './verificationCode'; @@ -80,7 +80,7 @@ export class ConnectionManager { if (!this.#current(operation)) return; discovered.set(desktop.desktopId, desktop); this.#set({ kind: 'scanning', saved, discovered: [...discovered.values()] }); - }, () => { void this.#handleScanFailure(operation, saved); }); + }, (error) => { void this.#handleScanFailure(operation, saved, error); }); } async connect(desktop: DiscoveredDesktop): Promise { @@ -94,8 +94,10 @@ export class ConnectionManager { const resolved = await this.transport.resolveAndConnect(desktop.desktopId); if (!this.#current(operation)) return; await this.#connectDesktop(resolved, operation, true, true); - } catch { - if (this.#current(operation)) await this.#fail('Could not connect to this PC.', operation); + } catch (error) { + if (!this.#current(operation)) return; + if (error instanceof BluetoothDeviceSelectionCancelledError) await this.#selectionCancelled(operation); + else await this.#fail('Could not connect to this PC.', operation); } } @@ -140,8 +142,10 @@ export class ConnectionManager { const resolved = await this.transport.resolveAndConnect(pc.desktopId); if (!this.#current(operation)) return; await this.#connectDesktop(resolved, operation, true, true, token); - } catch { - if (this.#current(operation)) await this.#fail('Could not find this PC nearby.', operation); + } catch (error) { + if (!this.#current(operation)) return; + if (error instanceof BluetoothDeviceSelectionCancelledError) await this.#selectionCancelled(operation); + else await this.#fail('Could not find this PC nearby.', operation); } } @@ -545,14 +549,28 @@ export class ConnectionManager { return defaultId ? [...saved].sort((a, b) => Number(b.desktopId === defaultId) - Number(a.desktopId === defaultId)) : saved; } - async #handleScanFailure(operation: number, saved: SavedPc[]): Promise { + async #handleScanFailure(operation: number, saved: SavedPc[], error?: Error): Promise { if (!this.#current(operation)) return; + if (error instanceof BluetoothDeviceSelectionCancelledError) { + this.#scanStop?.(); this.#scanStop = null; + this.diagnostics.add('scan_cancelled'); + this.#set({ kind: 'idle', saved }); + return; + } const availability = await this.transport.availability().catch(() => 'poweredOff' as BleAvailability); if (!this.#current(operation)) return; this.diagnostics.add('scan_failed', 'error'); this.#set(availability === 'ready' ? { kind: 'failed', message: 'Bluetooth discovery could not start.', saved } : this.#availabilityState(availability, saved)); } + async #selectionCancelled(operation: number): Promise { + await this.#teardownConnection(); + if (!this.#current(operation)) return; + this.diagnostics.add('scan_cancelled'); + const saved = await this.#orderedSaved(); + if (this.#current(operation)) this.#set({ kind: 'idle', saved }); + } + #availabilityState(availability: Exclude, saved: SavedPc[]): ConnectionState { if (availability === 'unauthorized') return { kind: 'permissionDenied', saved }; if (availability === 'unsupported') return { kind: 'unsupported', saved }; diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts index c82ed1a..cee4fe4 100644 --- a/src/connection/connection.test.ts +++ b/src/connection/connection.test.ts @@ -1,6 +1,6 @@ import { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; import type { PairingStorage, SavedPc } from '@/storage/PairingStore'; -import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from '@/transport/BleTransport'; +import { BluetoothDeviceSelectionCancelledError, type BleAvailability, type BleTransport, type DiscoveredDesktop, type Unsubscribe } from '@/transport/BleTransport'; import { ConnectionManager } from './ConnectionManager'; import { pairingVerificationCode } from './verificationCode'; @@ -33,8 +33,10 @@ class FakeTransport implements BleTransport { failConnect = false; failReadiness = false; connectGate: Promise | null = null; - scan(onDesktop: (desktop: DiscoveredDesktop) => void): Unsubscribe { + scanError: ((error: Error) => void) | null = null; + scan(onDesktop: (desktop: DiscoveredDesktop) => void, onError: (error: Error) => void): Unsubscribe { this.scanCallback = onDesktop; + this.scanError = onError; return () => { this.scanStops += 1; this.scanCallback = null; }; } connect = async (peripheralId: string) => { @@ -70,6 +72,31 @@ describe('connection lifecycle', () => { expect(pairingVerificationCode('desktop', 'device', 'nonce-14')).toBe('028314'); }); + it('returns to idle when the device picker is closed during discovery', async () => { + const transport = new FakeTransport(); + const diagnostics = new DiagnosticLog(); + const manager = new ConnectionManager(transport, new FakeStorage(), diagnostics, async () => true); + await manager.scan(); + transport.scanError?.(new BluetoothDeviceSelectionCancelledError()); + await waitFor(() => manager.snapshot().kind === 'idle'); + expect(transport.scanStops).toBe(1); + expect(diagnostics.snapshot().map((entry) => entry.code)).toContain('scan_cancelled'); + expect(diagnostics.snapshot().map((entry) => entry.code)).not.toContain('scan_failed'); + }); + + it('returns to idle, not failed, when the picker is closed while connecting', async () => { + const transport = new FakeTransport(); + const storage = new FakeStorage(); + storage.saved = [pc('pc-1')]; + storage.tokens.set('pc-1', 'token'); + transport.resolveError = new BluetoothDeviceSelectionCancelledError(); + const manager = new ConnectionManager(transport, storage, new DiagnosticLog(), async () => true); + await manager.connectSaved(pc('pc-1')); + expect(manager.snapshot()).toMatchObject({ kind: 'idle', saved: [{ desktopId: 'pc-1' }] }); + await manager.connect({ ...pc('pc-1'), rssi: null }); + expect(manager.snapshot().kind).toBe('idle'); + }); + it('discovers PCs through an injectable fake without hardware', async () => { const transport = new FakeTransport(); const manager = new ConnectionManager(transport, new FakeStorage(), new DiagnosticLog(), async () => true); diff --git a/src/connection/permissions.test.ts b/src/connection/permissions.test.ts index 166f7f5..254dc61 100644 --- a/src/connection/permissions.test.ts +++ b/src/connection/permissions.test.ts @@ -1,4 +1,4 @@ -import { bluetoothPermissionRecoveryMessage } from './permissions'; +import { bluetoothPermissionRecoveryMessage, bluetoothPromptMessage, bluetoothUnsupportedMessage } from './permissions'; describe('bluetoothPermissionRecoveryMessage', () => { it('uses iOS Bluetooth Settings guidance', () => { @@ -12,4 +12,22 @@ describe('bluetoothPermissionRecoveryMessage', () => { 'Allow Bluetooth and nearby-device access in system settings, then try again.', ); }); + + it('points browsers at site settings', () => { + expect(bluetoothPermissionRecoveryMessage('web')).toBe( + 'Allow Bluetooth for this site in your browser settings, then try again.', + ); + }); +}); + +describe('Bluetooth guidance by platform', () => { + it('describes the browser device chooser on the web', () => { + expect(bluetoothPromptMessage('web')).toBe('Your browser will ask you to choose your PC after you choose Allow Bluetooth.'); + expect(bluetoothPromptMessage('android')).toBe('Your device will show its Bluetooth permission prompt after you choose Allow Bluetooth.'); + }); + + it('names supported browsers when Web Bluetooth is missing', () => { + expect(bluetoothUnsupportedMessage('web')).toContain('Chrome or Edge'); + expect(bluetoothUnsupportedMessage('ios')).toBe('This device cannot use the Bluetooth features required by Switchify Remote.'); + }); }); diff --git a/src/connection/permissions.ts b/src/connection/permissions.ts index e3e6439..3018a6e 100644 --- a/src/connection/permissions.ts +++ b/src/connection/permissions.ts @@ -13,7 +13,20 @@ export async function requestBluetoothPermission(): Promise { } export function bluetoothPermissionRecoveryMessage(platform: typeof Platform.OS = Platform.OS): string { + if (platform === 'web') return 'Allow Bluetooth for this site in your browser settings, then try again.'; return platform === 'ios' ? 'Allow Bluetooth in Settings, then try again.' : 'Allow Bluetooth and nearby-device access in system settings, then try again.'; } + +export function bluetoothPromptMessage(platform: typeof Platform.OS = Platform.OS): string { + return platform === 'web' + ? 'Your browser will ask you to choose your PC after you choose Allow Bluetooth.' + : 'Your device will show its Bluetooth permission prompt after you choose Allow Bluetooth.'; +} + +export function bluetoothUnsupportedMessage(platform: typeof Platform.OS = Platform.OS): string { + return platform === 'web' + ? 'This browser cannot use Bluetooth. Open Switchify Remote in Chrome or Edge on Android, Windows, macOS or ChromeOS.' + : 'This device cannot use the Bluetooth features required by Switchify Remote.'; +} diff --git a/src/diagnostics/DiagnosticLog.ts b/src/diagnostics/DiagnosticLog.ts index b1c7490..549f99c 100644 --- a/src/diagnostics/DiagnosticLog.ts +++ b/src/diagnostics/DiagnosticLog.ts @@ -21,6 +21,7 @@ export type ConnectionStageOutcome = 'started' | 'succeeded' | 'failed' | 'not_m const messages = { scan_started: 'Looking for nearby PCs.', scan_failed: 'Bluetooth discovery could not start.', + scan_cancelled: 'No PC was chosen in the Bluetooth device picker.', connecting: 'Connecting to a PC.', connected: 'Connected to a PC.', connection_lost: 'The connection to the PC was lost.', diff --git a/src/diagnostics/exportDiagnostics.ts b/src/diagnostics/exportDiagnostics.ts new file mode 100644 index 0000000..7ee40f4 --- /dev/null +++ b/src/diagnostics/exportDiagnostics.ts @@ -0,0 +1,9 @@ +import * as FileSystem from 'expo-file-system/legacy'; +import * as Sharing from 'expo-sharing'; + +export async function exportDiagnostics(contents: string): Promise { + if (!FileSystem.cacheDirectory || !await Sharing.isAvailableAsync()) return; + const path = `${FileSystem.cacheDirectory}switchify-remote-diagnostics.txt`; + await FileSystem.writeAsStringAsync(path, contents); + await Sharing.shareAsync(path, { mimeType: 'text/plain', dialogTitle: 'Export Switchify Remote diagnostics' }); +} diff --git a/src/diagnostics/exportDiagnostics.web.ts b/src/diagnostics/exportDiagnostics.web.ts new file mode 100644 index 0000000..92a67e3 --- /dev/null +++ b/src/diagnostics/exportDiagnostics.web.ts @@ -0,0 +1,14 @@ +export async function exportDiagnostics(contents: string): Promise { + if (typeof document === 'undefined') return; + const url = URL.createObjectURL(new Blob([contents], { type: 'text/plain' })); + try { + const link = document.createElement('a'); + link.href = url; + link.download = 'switchify-remote-diagnostics.txt'; + document.body.appendChild(link); + link.click(); + link.remove(); + } finally { + setTimeout(() => URL.revokeObjectURL(url), 0); + } +} diff --git a/src/onboarding/FirstRunSetup.tsx b/src/onboarding/FirstRunSetup.tsx index d265bb1..beb2bce 100644 --- a/src/onboarding/FirstRunSetup.tsx +++ b/src/onboarding/FirstRunSetup.tsx @@ -16,6 +16,7 @@ import { Screen } from '@/components/Screen'; import { useAccessibilityAnnouncement } from '@/components/useAccessibilityAnnouncement'; import { useConnectionManager } from '@/connection/ConnectionContext'; import type { ConnectionManager } from '@/connection/ConnectionManager'; +import { bluetoothPromptMessage } from '@/connection/permissions'; import { useTheme } from '@/theme/ThemeContext'; import { firstRunSetupStore, @@ -232,10 +233,7 @@ function BluetoothStep({ text="Remote commands are not routed through a Switchify account or cloud service." /> - - Your device will show its Bluetooth permission prompt after you choose - Allow Bluetooth. - + {bluetoothPromptMessage()} { + const region = liveRegion(); + const value = pending.shift(); + if (!region || value === undefined) { + pending = []; + flushing = false; + return; + } + // Clearing first makes screen readers repeat an identical message. + region.textContent = ''; + setTimeout(() => { + region.textContent = value; + setTimeout(next, 500); + }, 50); + }; + next(); +} + +/** + * React Native Web's Alert does nothing. The app's alerts are either notices or a + * cancel plus one action, which map onto the browser's own accessible dialogs. + */ +const alert: typeof Alert.alert = (title, message, buttons) => { + if (typeof window === 'undefined') return; + const text = message ? `${title}\n\n${message}` : title; + const choices = buttons ?? []; + if (choices.length <= 1) { + window.alert(text); + choices[0]?.onPress?.(); + return; + } + const cancel = choices.find((button) => button.style === 'cancel') ?? choices[0]; + const action = choices.find((button) => button !== cancel); + if (window.confirm(text)) action?.onPress?.(); + else cancel?.onPress?.(); +}; + +/** React Native Web leaves alerts, screen-reader announcements and focus moves as no-ops; route them to the DOM. */ +export function installPlatform(): void { + if (installed) return; + installed = true; + Alert.alert = alert; + const info = AccessibilityInfo as typeof AccessibilityInfo & { + announceForAccessibilityWithOptions?: (message: string, options: { queue?: boolean }) => void; + }; + info.announceForAccessibility = announce; + info.announceForAccessibilityWithOptions = (message) => announce(message); +} diff --git a/src/storage/PairingStore.ts b/src/storage/PairingStore.ts index 0c1a67f..888ebda 100644 --- a/src/storage/PairingStore.ts +++ b/src/storage/PairingStore.ts @@ -2,6 +2,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Crypto from 'expo-crypto'; import * as SecureStore from 'expo-secure-store'; +import { secretStorage as platformSecretStorage, type SecretStorage } from './secretStorage'; + import type { PcPlatform } from '@/domain/protocol/types'; const INDEX_KEY = 'switchify.remote.pairings.v1'; @@ -27,12 +29,11 @@ export interface PairingStorage { } type PublicStorage = Pick; -type SecretStorage = Pick; export class PairingStore implements PairingStorage { #storageQueue: Promise = Promise.resolve(); - constructor(private readonly publicStorage: PublicStorage = AsyncStorage, private readonly secretStorage: SecretStorage = SecureStore) {} + constructor(private readonly publicStorage: PublicStorage = AsyncStorage, private readonly secretStorage: SecretStorage = platformSecretStorage) {} async getDeviceId(): Promise { const existing = await this.secretStorage.getItemAsync(DEVICE_ID_KEY); diff --git a/src/storage/secretStorage.ts b/src/storage/secretStorage.ts new file mode 100644 index 0000000..08ee111 --- /dev/null +++ b/src/storage/secretStorage.ts @@ -0,0 +1,5 @@ +import * as SecureStore from 'expo-secure-store'; + +export type SecretStorage = Pick; + +export const secretStorage: SecretStorage = SecureStore; diff --git a/src/storage/secretStorage.web.ts b/src/storage/secretStorage.web.ts new file mode 100644 index 0000000..912751b --- /dev/null +++ b/src/storage/secretStorage.web.ts @@ -0,0 +1,97 @@ +import type * as SecureStore from 'expo-secure-store'; + +export type SecretStorage = Pick; + +const DATABASE = 'switchify-remote-secrets'; +const STORE = 'keys'; +const KEY_ID = 'aes-gcm-v1'; +const VALUE_PREFIX = 'switchify.remote.secret.v1.'; + +let keyPromise: Promise | null = null; + +function requireSecureContext(): void { + if (typeof window === 'undefined' || !window.isSecureContext || !window.crypto?.subtle || !window.indexedDB) { + throw new Error('Secure storage is unavailable in this browser.'); + } +} + +function request(operation: IDBRequest): Promise { + return new Promise((resolve, reject) => { + operation.onsuccess = () => resolve(operation.result); + operation.onerror = () => reject(new Error('Secure storage is unavailable in this browser.')); + }); +} + +async function openDatabase(): Promise { + const open = window.indexedDB.open(DATABASE, 1); + open.onupgradeneeded = () => open.result.createObjectStore(STORE); + return await request(open); +} + +/** A non-extractable key: the page can use it, but it cannot be read out of the browser. */ +async function loadKey(): Promise { + requireSecureContext(); + const database = await openDatabase(); + try { + const existing = await request(database.transaction(STORE, 'readonly').objectStore(STORE).get(KEY_ID)) as CryptoKey | undefined; + if (existing) return existing; + const created = await window.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + const store = database.transaction(STORE, 'readwrite').objectStore(STORE); + const raced = await request(store.get(KEY_ID)) as CryptoKey | undefined; + if (raced) return raced; + await request(store.add(created, KEY_ID)); + return created; + } finally { + database.close(); + } +} + +function key(): Promise { + keyPromise ??= loadKey().catch((error: unknown) => { + keyPromise = null; + throw error; + }); + return keyPromise; +} + +function toBase64(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function fromBase64(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +async function getItemAsync(name: string): Promise { + requireSecureContext(); + const stored = window.localStorage.getItem(`${VALUE_PREFIX}${name}`); + if (stored === null) return null; + const [iv, data] = stored.split('.'); + if (!iv || !data) return null; + try { + const plain = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromBase64(iv), additionalData: new TextEncoder().encode(name) }, await key(), fromBase64(data)); + return new TextDecoder().decode(plain); + } catch { + // A value the current key cannot open is unrecoverable, so it is treated as absent. + return null; + } +} + +async function setItemAsync(name: string, value: string): Promise { + requireSecureContext(); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const data = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(name) }, await key(), new TextEncoder().encode(value)); + window.localStorage.setItem(`${VALUE_PREFIX}${name}`, `${toBase64(iv)}.${toBase64(new Uint8Array(data))}`); +} + +async function deleteItemAsync(name: string): Promise { + requireSecureContext(); + window.localStorage.removeItem(`${VALUE_PREFIX}${name}`); +} + +export const secretStorage = { getItemAsync, setItemAsync, deleteItemAsync } as SecretStorage; diff --git a/src/theme/ThemeContext.tsx b/src/theme/ThemeContext.tsx index e77b09f..b8ca772 100644 --- a/src/theme/ThemeContext.tsx +++ b/src/theme/ThemeContext.tsx @@ -1,6 +1,7 @@ import { createContext, type PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'; import { AccessibilityInfo, useColorScheme, useWindowDimensions } from 'react-native'; +import { subscribeReducedTransparency } from './reducedTransparency'; import { palettes, radii, spacing, typography } from './tokens'; type ThemeValue = { @@ -22,12 +23,11 @@ export function ThemeProvider({ children }: PropsWithChildren) { const [reducedTransparency, setReducedTransparency] = useState(false); useEffect(() => { void AccessibilityInfo.isReduceMotionEnabled().then(setReducedMotion); - void AccessibilityInfo.isReduceTransparencyEnabled().then(setReducedTransparency); const motionSubscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setReducedMotion); - const transparencySubscription = AccessibilityInfo.addEventListener('reduceTransparencyChanged', setReducedTransparency); + const transparencySubscription = subscribeReducedTransparency(setReducedTransparency); return () => { motionSubscription.remove(); - transparencySubscription.remove(); + transparencySubscription(); }; }, []); const value = useMemo(() => ({ colors: palettes[scheme], radii, reducedMotion, reducedTransparency, scheme, spacing, typography }), [reducedMotion, reducedTransparency, scheme]); diff --git a/src/theme/reducedTransparency.ts b/src/theme/reducedTransparency.ts new file mode 100644 index 0000000..6fd3019 --- /dev/null +++ b/src/theme/reducedTransparency.ts @@ -0,0 +1,7 @@ +import { AccessibilityInfo } from 'react-native'; + +export function subscribeReducedTransparency(onChange: (enabled: boolean) => void): () => void { + void AccessibilityInfo.isReduceTransparencyEnabled().then(onChange); + const subscription = AccessibilityInfo.addEventListener('reduceTransparencyChanged', onChange); + return () => subscription.remove(); +} diff --git a/src/theme/reducedTransparency.web.ts b/src/theme/reducedTransparency.web.ts new file mode 100644 index 0000000..970cc5a --- /dev/null +++ b/src/theme/reducedTransparency.web.ts @@ -0,0 +1,8 @@ +export function subscribeReducedTransparency(onChange: (enabled: boolean) => void): () => void { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return () => undefined; + const query = window.matchMedia('(prefers-reduced-transparency: reduce)'); + const listener = (event: MediaQueryListEvent) => onChange(event.matches); + onChange(query.matches); + query.addEventListener('change', listener); + return () => query.removeEventListener('change', listener); +} diff --git a/src/transport/BleTransport.ts b/src/transport/BleTransport.ts index e2dda85..79fe1ad 100644 --- a/src/transport/BleTransport.ts +++ b/src/transport/BleTransport.ts @@ -18,3 +18,8 @@ export interface BleTransport { notificationsReady(): Promise; subscribeDisconnect(onDisconnect: () => void): Unsubscribe; } + +/** The person closed a device chooser without choosing a PC, as browsers require for Web Bluetooth. */ +export class BluetoothDeviceSelectionCancelledError extends Error { + constructor() { super('Bluetooth device selection was cancelled.'); } +} diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts new file mode 100644 index 0000000..5717e45 --- /dev/null +++ b/src/transport/WebBluetoothTransport.test.ts @@ -0,0 +1,247 @@ +import { fromByteArray, toByteArray } from 'base64-js'; + +import { BLE_UUIDS } from '@/domain/protocol/constants'; +import { BluetoothDeviceSelectionCancelledError } from './BleTransport'; +import { + WEB_MAX_WRITE_VALUE_BYTES, + WebBluetoothTransport, + type WebBluetooth, + type WebBluetoothCharacteristic, + type WebBluetoothDevice, + type WebBluetoothServer, + type WebBluetoothService, +} from './WebBluetoothTransport'; + +const encoder = new TextEncoder(); +const view = (text: string) => new DataView(encoder.encode(text).buffer); +const status = (desktopId: string, extra: Record = {}) => JSON.stringify({ protocolVersion: 1, desktopId, displayName: 'Office PC', platform: 'windows', ...extra }); + +class FakeCharacteristic extends EventTarget implements WebBluetoothCharacteristic { + value: DataView | null = null; + writes: Uint8Array[] = []; + reads: (() => DataView)[] = []; + notifying = false; + constructor(private readonly peripheral: FakePeripheral, readonly uuid: string) { super(); } + async readValue(): Promise { + return this.peripheral.operation(() => { + if (this.uuid === BLE_UUIDS.status) return view(this.peripheral.status); + return this.reads.shift()?.() ?? new DataView(new ArrayBuffer(0)); + }); + } + async writeValueWithResponse(value: BufferSource): Promise { + await this.peripheral.operation(() => { this.writes.push(new Uint8Array(value as ArrayBuffer)); }, this.peripheral.writeGate); + } + async startNotifications(): Promise { return this.peripheral.operation(() => { this.notifying = true; return this; }); } + async stopNotifications(): Promise { this.notifying = false; return this; } + notify(text: string): void { + this.value = view(text); + this.dispatchEvent(new Event('characteristicvaluechanged')); + } +} + +class FakePeripheral extends EventTarget implements WebBluetoothDevice, WebBluetoothServer { + connected = false; + busy = false; + overlapping = false; + connects = 0; + writeGate: Promise | null = null; + characteristics: Record; + constructor(readonly id: string, public status: string, readonly name: string | null = 'Office PC', withResponse = false) { + super(); + const uuids: string[] = [BLE_UUIDS.receive, BLE_UUIDS.transmit, BLE_UUIDS.status, ...(withResponse ? [BLE_UUIDS.response] : [])]; + this.characteristics = Object.fromEntries(uuids.map((uuid) => [uuid, new FakeCharacteristic(this, uuid)])); + } + get gatt(): WebBluetoothServer { return this; } + async connect(): Promise { this.connects += 1; this.connected = true; return this; } + disconnect(): void { + if (!this.connected) return; + this.connected = false; + this.dispatchEvent(new Event('gattserverdisconnected')); + } + async getPrimaryService(uuid: string): Promise { + if (uuid !== BLE_UUIDS.service) throw new Error('missing service'); + return { + getCharacteristic: async (characteristic: string) => { + const found = this.characteristics[characteristic]; + if (!found) throw new Error('missing characteristic'); + return found; + }, + }; + } + async operation(action: () => T, gate: Promise | null = null): Promise { + if (this.busy) this.overlapping = true; + this.busy = true; + try { + await Promise.resolve(); + await gate; + return action(); + } finally { + this.busy = false; + } + } +} + +class FakeBluetooth implements WebBluetooth { + chooser: (() => Promise) | null = null; + requests = 0; + remembered: WebBluetoothDevice[] = []; + available = true; + async getAvailability(): Promise { return this.available; } + async getDevices(): Promise { return this.remembered; } + async requestDevice(): Promise { + this.requests += 1; + if (!this.chooser) throw Object.assign(new Error('User cancelled the requestDevice() chooser.'), { name: 'NotFoundError' }); + return this.chooser(); + } +} + +const flush = async () => { for (let index = 0; index < 20; index += 1) await Promise.resolve(); }; + +describe('WebBluetoothTransport', () => { + it('reports when the browser has no Web Bluetooth', async () => { + expect(await new WebBluetoothTransport(() => null).availability()).toBe('unsupported'); + const bluetooth = new FakeBluetooth(); + bluetooth.available = false; + expect(await new WebBluetoothTransport(() => bluetooth).availability()).toBe('poweredOff'); + }); + + it('turns a device chosen in the browser picker into a discovered PC', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const found = jest.fn(); + transport.scan(found, jest.fn()); + await flush(); + expect(found).toHaveBeenCalledWith({ desktopId: 'desk-1', displayName: 'Office PC', platform: 'windows', peripheralId: 'web-1', rssi: null }); + }); + + it('reports a closed picker as a cancelled selection, not a failure', async () => { + const bluetooth = new FakeBluetooth(); + const transport = new WebBluetoothTransport(() => bluetooth); + const onError = jest.fn(); + transport.scan(jest.fn(), onError); + await flush(); + expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(BluetoothDeviceSelectionCancelledError); + await expect(transport.resolveAndConnect('desk-1')).rejects.toBeInstanceOf(BluetoothDeviceSelectionCancelledError); + }); + + it('reconnects to a chosen PC without asking again and closes the discovery probe on stop', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const stop = transport.scan(jest.fn(), jest.fn()); + await flush(); + stop(); + expect(peripheral.connected).toBe(false); + await expect(transport.resolveAndConnect('desk-1')).resolves.toMatchObject({ desktopId: 'desk-1', peripheralId: 'web-1' }); + expect(bluetooth.requests).toBe(1); + expect(peripheral.connected).toBe(true); + expect(transport.maxWriteValueBytes()).toBe(WEB_MAX_WRITE_VALUE_BYTES); + }); + + it('finds a saved PC among devices the browser remembers', async () => { + const bluetooth = new FakeBluetooth(); + const other = new FakePeripheral('web-0', status('desk-0')); + const saved = new FakePeripheral('web-1', status('desk-1')); + bluetooth.remembered = [other, saved]; + const transport = new WebBluetoothTransport(() => bluetooth); + await expect(transport.resolveAndConnect('desk-1')).resolves.toMatchObject({ peripheralId: 'web-1' }); + expect(bluetooth.requests).toBe(0); + expect(other.connected).toBe(false); + }); + + it('refuses a different PC chosen for a saved one', async () => { + const bluetooth = new FakeBluetooth(); + const wrong = new FakePeripheral('web-2', status('desk-2')); + bluetooth.chooser = async () => wrong; + const transport = new WebBluetoothTransport(() => bluetooth); + await expect(transport.resolveAndConnect('desk-1')).rejects.toThrow('different PC'); + expect(wrong.connected).toBe(false); + expect(() => transport.maxWriteValueBytes()).toThrow('No PC is connected.'); + }); + + it('rejects an invalid status', async () => { + const bluetooth = new FakeBluetooth(); + bluetooth.chooser = async () => new FakePeripheral('web-1', '{"protocolVersion":2}'); + await expect(new WebBluetoothTransport(() => bluetooth).resolveAndConnect('desk-1')).rejects.toThrow('status is invalid'); + }); + + it('runs GATT operations one at a time and bounds frame size', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + await transport.resolveAndConnect('desk-1'); + const frames = ['YQ==', 'Yg==', 'Yw==']; + await Promise.all([...frames.map((frame) => transport.writeFrame(frame)), transport.verifyConnection('desk-1')]); + expect(peripheral.overlapping).toBe(false); + expect(peripheral.characteristics[BLE_UUIDS.receive]!.writes.map((bytes) => fromByteArray(bytes))).toEqual(frames); + await expect(transport.writeFrame(fromByteArray(new Uint8Array(WEB_MAX_WRITE_VALUE_BYTES + 1)))).rejects.toThrow('too large'); + }); + + it('delivers notifications as base64 frames once they are enabled', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + await transport.resolveAndConnect('desk-1'); + const frames: string[] = []; + const unsubscribe = transport.subscribe((frame) => frames.push(frame), jest.fn()); + await transport.notificationsReady(); + peripheral.characteristics[BLE_UUIDS.transmit]!.notify('{"ok":true}'); + expect(frames.map((frame) => new TextDecoder().decode(toByteArray(frame)))).toEqual(['{"ok":true}']); + unsubscribe(); + peripheral.characteristics[BLE_UUIDS.transmit]!.notify('{"late":true}'); + expect(frames).toHaveLength(1); + }); + + it('polls the response characteristic for read-v1 PCs', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1', { responseTransport: 'read-v1' }), 'Office PC', true); + peripheral.characteristics[BLE_UUIDS.response]!.reads.push(() => view('{"reply":1}')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + await transport.resolveAndConnect('desk-1'); + const frames: string[] = []; + const unsubscribe = transport.subscribe((frame) => frames.push(frame), jest.fn()); + await transport.notificationsReady(); + await flush(); + expect(frames.map((frame) => new TextDecoder().decode(toByteArray(frame)))).toEqual(['{"reply":1}']); + unsubscribe(); + await transport.disconnect(); + }); + + it('reports disconnects only for the current session', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + await transport.resolveAndConnect('desk-1'); + const onDisconnect = jest.fn(); + transport.subscribeDisconnect(onDisconnect); + peripheral.disconnect(); + expect(onDisconnect).toHaveBeenCalledTimes(1); + await transport.disconnect(); + expect(await transport.verifyConnection('desk-1')).toBe(false); + }); + + it('requires a reconnect after cancelling a write already in flight', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + await transport.resolveAndConnect('desk-1'); + let release!: () => void; + peripheral.writeGate = new Promise((resolve) => { release = resolve; }); + const inFlight = transport.writeFrame('YQ=='); + const queued = transport.writeFrame('Yg=='); + await flush(); + await transport.cancelPendingWrites(); + await expect(queued).rejects.toThrow('cancelled'); + release(); + await inFlight; + await expect(transport.writeFrame('Yw==')).rejects.toThrow('until reconnect'); + }); +}); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts new file mode 100644 index 0000000..879bc07 --- /dev/null +++ b/src/transport/WebBluetoothTransport.ts @@ -0,0 +1,428 @@ +import { fromByteArray, toByteArray } from 'base64-js'; + +import { BLE_UUIDS } from '@/domain/protocol/constants'; +import { parseStatus } from '@/domain/protocol/responses'; +import type { PcStatus } from '@/domain/protocol/types'; +import type { ConnectionStage, ConnectionStageOutcome, DiagnosticLog } from '@/diagnostics/DiagnosticLog'; +import { BluetoothDeviceSelectionCancelledError, type BleAvailability, type BleTransport, type DiscoveredDesktop, type Unsubscribe } from './BleTransport'; +import { desktopDisplayName } from './desktopDisplayName'; +import { ReadResponsePoller } from './ReadResponsePoller'; + +/** + * Web Bluetooth does not report the negotiated MTU and macOS Switchify PC rejects + * long (offset) writes, so frames stay within a 185-byte ATT MTU. + */ +export const WEB_MAX_WRITE_VALUE_BYTES = 182; + +export interface WebBluetoothCharacteristic extends EventTarget { + readonly value?: DataView | null; + readValue(): Promise; + writeValueWithResponse(value: BufferSource): Promise; + startNotifications(): Promise; + stopNotifications(): Promise; +} + +export interface WebBluetoothService { + getCharacteristic(uuid: string): Promise; +} + +export interface WebBluetoothServer { + readonly connected: boolean; + connect(): Promise; + disconnect(): void; + getPrimaryService(uuid: string): Promise; +} + +export interface WebBluetoothDevice extends EventTarget { + readonly id: string; + readonly name?: string | null; + readonly gatt?: WebBluetoothServer | null; +} + +export interface WebBluetooth { + getAvailability?(): Promise; + getDevices?(): Promise; + requestDevice(options: { filters: { services: string[] }[]; optionalServices?: string[] }): Promise; +} + +type Characteristics = { + receive: WebBluetoothCharacteristic; + transmit: WebBluetoothCharacteristic; + status: WebBluetoothCharacteristic; + response: WebBluetoothCharacteristic | null; +}; + +type Session = { device: WebBluetoothDevice; server: WebBluetoothServer; characteristics: Characteristics; readReplies: boolean }; + +type Pending = { start: () => Promise; resolve: (value: unknown) => void; reject: (error: Error) => void; write: boolean }; + +export function navigatorBluetooth(): WebBluetooth | null { + if (typeof navigator === 'undefined') return null; + return (navigator as Navigator & { bluetooth?: WebBluetooth }).bluetooth ?? null; +} + +function decodeStatus(view: DataView): PcStatus | null { + return parseStatus(new TextDecoder().decode(new Uint8Array(view.buffer, view.byteOffset, view.byteLength))); +} + +function toBase64(view: DataView): string { + return fromByteArray(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); +} + +function isCancellation(error: unknown): boolean { + return error instanceof Error && error.name === 'NotFoundError'; +} + +export class WebBluetoothTransport implements BleTransport { + #operation = 0; + #session: Session | null = null; + #devices = new Map(); + #desktopDevices = new Map(); + #probes = new Set(); + #queue: Pending[] = []; + #running: Pending | null = null; + #writePoisoned = false; + #responsePoller: ReadResponsePoller | null = null; + #notificationsReady: Promise | null = null; + #timers = new Set<(error: Error) => void>(); + + constructor( + private readonly bluetooth: () => WebBluetooth | null = navigatorBluetooth, + private readonly timeoutMs = 10_000, + private readonly diagnostics?: Pick, + ) {} + + async availability(): Promise { + const bluetooth = this.bluetooth(); + if (!bluetooth) return 'unsupported'; + if (!bluetooth.getAvailability) return 'ready'; + try { + return await bluetooth.getAvailability() ? 'ready' : 'poweredOff'; + } catch { + return 'unsupported'; + } + } + + scan(onDesktop: (desktop: DiscoveredDesktop) => void, onError: (error: Error) => void): Unsubscribe { + const operation = ++this.#operation; + let active = true; + let probe: WebBluetoothDevice | null = null; + void (async () => { + const bluetooth = this.bluetooth(); + if (!bluetooth) throw new Error('Web Bluetooth is unavailable.'); + const device = await bluetooth.requestDevice({ filters: [{ services: [BLE_UUIDS.service] }] }); + if (!active || operation !== this.#operation) return; + probe = device; + this.#probes.add(device); + const desktop = await this.#readDesktop(device, operation); + if (!active || operation !== this.#operation) return; + if (desktop) onDesktop(desktop); + })().catch((error: unknown) => { + if (!active || operation !== this.#operation) return; + onError(isCancellation(error) ? new BluetoothDeviceSelectionCancelledError() : new Error('Bluetooth discovery failed.')); + }); + return () => { + if (!active) return; + active = false; + if (operation === this.#operation) this.#operation += 1; + if (probe) this.#releaseProbe(probe); + }; + } + + async connect(peripheralId: string): Promise { + await this.disconnect(); + const operation = ++this.#operation; + const device = this.#devices.get(peripheralId) ?? await this.#rememberedDevice((candidate) => candidate.id === peripheralId); + if (!device) throw new Error('This PC is not available to the browser. Choose it again.'); + await this.#open(device, operation); + } + + async resolveAndConnect(desktopId: string): Promise { + await this.disconnect(); + const operation = ++this.#operation; + this.#recordStage('resolution', 'started', operation); + try { + const known = this.#desktopDevices.get(desktopId); + let device = known ? this.#devices.get(known) ?? null : null; + device ??= await this.#rememberedDevice(() => true, desktopId, operation); + if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); + if (!device) { + const bluetooth = this.bluetooth(); + if (!bluetooth) throw new Error('Web Bluetooth is unavailable.'); + device = await bluetooth.requestDevice({ filters: [{ services: [BLE_UUIDS.service] }] }); + this.#devices.set(device.id, device); + } + if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); + const status = await this.#open(device, operation); + this.#recordStage('selected_match', status.desktopId === desktopId ? 'succeeded' : 'not_matched', operation); + if (status.desktopId !== desktopId) throw new Error('The chosen device is a different PC.'); + this.#recordStage('resolution', 'succeeded', operation); + return this.#describe(device, status); + } catch (error) { + this.#recordStage('resolution', 'failed', operation); + if (operation === this.#operation) await this.disconnect(); + throw isCancellation(error) ? new BluetoothDeviceSelectionCancelledError() : error; + } + } + + async disconnect(): Promise { + this.#operation += 1; + this.#responsePoller?.stop(); + this.#responsePoller = null; + this.#notificationsReady = null; + this.#cancelTimers(); + await this.cancelPendingWrites(); + this.#writePoisoned = false; + for (const probe of [...this.#probes]) this.#releaseProbe(probe); + const session = this.#session; + this.#session = null; + if (session?.server.connected) { + try { session.server.disconnect(); } catch { /* Already disconnected. */ } + } + } + + maxWriteValueBytes(): number { + this.#requireSession(); + return WEB_MAX_WRITE_VALUE_BYTES; + } + + async writeFrame(frameBase64: string): Promise { + const session = this.#requireSession(); + if (this.#writePoisoned) throw new Error('Bluetooth writes are unavailable until reconnect.'); + const bytes = new Uint8Array(toByteArray(frameBase64)); + if (bytes.length > WEB_MAX_WRITE_VALUE_BYTES) throw new Error('Bluetooth frame is too large.'); + await this.#bounded(this.#gatt(() => session.characteristics.receive.writeValueWithResponse(bytes), true), 'Bluetooth write timed out.'); + } + + async cancelPendingWrites(): Promise { + const error = new Error('Bluetooth write was cancelled.'); + const queued = this.#queue.filter((item) => item.write); + this.#queue = this.#queue.filter((item) => !item.write); + queued.forEach((item) => item.reject(error)); + // Web Bluetooth cannot abort a write already sent to the PC. + if (this.#running?.write) this.#writePoisoned = true; + } + + async verifyConnection(desktopId: string): Promise { + const session = this.#session; + if (!session?.server.connected) return false; + try { + const view = await this.#bounded(this.#gatt(() => session.characteristics.status.readValue()), 'Bluetooth operation timed out.', Math.min(4_000, this.timeoutMs)); + return this.#session === session && decodeStatus(view)?.desktopId === desktopId; + } catch { + return false; + } + } + + subscribe(onFrame: (frameBase64: string) => void, onError: (error: Error) => void): Unsubscribe { + const session = this.#requireSession(); + const operation = this.#operation; + if (session.readReplies) { + const response = session.characteristics.response; + if (!response || this.#responsePoller || this.#writePoisoned) throw new Error('Reconnect before starting response reads again.'); + const poller = new ReadResponsePoller( + async () => toBase64(await this.#gatt(() => response.readValue())), + async () => undefined, + (frame) => { if (operation === this.#operation) onFrame(frame); }, + (error) => { if (operation === this.#operation) { this.#writePoisoned = true; onError(error); } }, + this.timeoutMs, + ); + this.#responsePoller = poller; + return () => { poller.stop(); if (this.#responsePoller === poller) this.#writePoisoned = true; }; + } + const transmit = session.characteristics.transmit; + let active = true; + const listener = (event: Event) => { + if (!active || operation !== this.#operation) return; + const value = (event.target as WebBluetoothCharacteristic | null)?.value; + if (value) onFrame(toBase64(value)); + }; + transmit.addEventListener('characteristicvaluechanged', listener); + this.#recordStage('notifications', 'started', operation); + const ready = this.#bounded(this.#gatt(() => transmit.startNotifications()), 'Bluetooth notifications timed out.').then(() => { + this.#recordStage('notifications', 'succeeded', operation); + }, (error: unknown) => { + this.#recordStage('notifications', 'failed', operation); + if (active && operation === this.#operation) onError(new Error('Bluetooth notifications could not be enabled.')); + throw error; + }); + void ready.catch(() => undefined); + this.#notificationsReady = ready; + return () => { + active = false; + transmit.removeEventListener('characteristicvaluechanged', listener); + if (this.#session === session && session.server.connected) void this.#gatt(() => transmit.stopNotifications()).catch(() => undefined); + }; + } + + async notificationsReady(): Promise { + if (this.#responsePoller) { await this.#responsePoller.ready; return; } + if (!this.#notificationsReady) throw new Error('Bluetooth notifications have not started.'); + await this.#notificationsReady; + } + + subscribeDisconnect(onDisconnect: () => void): Unsubscribe { + const session = this.#requireSession(); + const listener = () => { if (this.#session === session) onDisconnect(); }; + session.device.addEventListener('gattserverdisconnected', listener); + return () => session.device.removeEventListener('gattserverdisconnected', listener); + } + + async #open(device: WebBluetoothDevice, operation: number): Promise { + this.#devices.set(device.id, device); + this.#probes.delete(device); + const gatt = device.gatt; + if (!gatt) throw new Error('This device does not support Bluetooth connections.'); + const server = await this.#stage('connect', () => this.#bounded(gatt.connect(), 'Bluetooth operation timed out.'), operation); + if (operation !== this.#operation) { server.disconnect(); throw new Error('Bluetooth connection was cancelled.'); } + try { + const characteristics = await this.#stage('services', () => this.#bounded(this.#characteristics(server), 'Bluetooth operation timed out.'), operation); + if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); + const view = await this.#stage('status_read', () => this.#bounded(this.#gatt(() => characteristics.status.readValue()), 'Bluetooth operation timed out.'), operation); + const status = decodeStatus(view); + if (!status) throw new Error('Bluetooth discovery status is invalid.'); + if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); + const readReplies = status.responseTransport === 'read-v1'; + if (readReplies && !characteristics.response) throw new Error('Bluetooth discovery status is invalid.'); + this.#desktopDevices.set(status.desktopId, device.id); + this.#session = { device, server, characteristics, readReplies }; + this.#writePoisoned = false; + return status; + } catch (error) { + try { server.disconnect(); } catch { /* Already disconnected. */ } + throw error; + } + } + + async #characteristics(server: WebBluetoothServer): Promise { + const service = await server.getPrimaryService(BLE_UUIDS.service); + const [receive, transmit, status, response] = await Promise.all([ + service.getCharacteristic(BLE_UUIDS.receive), + service.getCharacteristic(BLE_UUIDS.transmit), + service.getCharacteristic(BLE_UUIDS.status), + service.getCharacteristic(BLE_UUIDS.response).catch(() => null), + ]); + return { receive, transmit, status, response }; + } + + async #readDesktop(device: WebBluetoothDevice, operation: number): Promise { + const gatt = device.gatt; + if (!gatt) return null; + const server = await this.#stage('probe_connect', () => this.#bounded(gatt.connect(), 'Bluetooth operation timed out.'), operation); + const service = await this.#stage('probe_services', () => this.#bounded(server.getPrimaryService(BLE_UUIDS.service), 'Bluetooth operation timed out.'), operation); + const characteristic = await this.#bounded(service.getCharacteristic(BLE_UUIDS.status), 'Bluetooth operation timed out.'); + const view = await this.#stage('status_read', () => this.#bounded(this.#gatt(() => characteristic.readValue()), 'Bluetooth operation timed out.'), operation); + this.#recordStage('status_parse', 'started', operation); + const status = decodeStatus(view); + this.#recordStage('status_parse', status ? 'succeeded' : 'failed', operation); + if (!status) return null; + this.#devices.set(device.id, device); + this.#desktopDevices.set(status.desktopId, device.id); + return this.#describe(device, status); + } + + /** Devices this site was allowed before, where the browser supports remembering them. */ + async #rememberedDevice(matches: (device: WebBluetoothDevice) => boolean, desktopId?: string, operation = this.#operation): Promise { + const bluetooth = this.bluetooth(); + if (!bluetooth?.getDevices) return null; + let devices: WebBluetoothDevice[]; + try { devices = (await bluetooth.getDevices()).filter(matches); } catch { return null; } + for (const device of devices) { + this.#devices.set(device.id, device); + if (desktopId === undefined) return device; + if (this.#desktopDevices.get(desktopId) === device.id) return device; + } + if (desktopId === undefined) return null; + for (const device of devices) { + if (operation !== this.#operation) return null; + try { + const desktop = await this.#readDesktop(device, operation); + this.#releaseProbe(device); + if (desktop?.desktopId === desktopId) return device; + } catch { + this.#releaseProbe(device); + } + } + return null; + } + + #describe(device: WebBluetoothDevice, status: PcStatus): DiscoveredDesktop { + return { + ...status, + displayName: desktopDisplayName(status, { name: device.name, localName: null }, 'web'), + peripheralId: device.id, + rssi: null, + }; + } + + #releaseProbe(device: WebBluetoothDevice): void { + this.#probes.delete(device); + if (this.#session?.device === device) return; + try { if (device.gatt?.connected) device.gatt.disconnect(); } catch { /* Already disconnected. */ } + } + + /** Web Bluetooth rejects overlapping GATT operations, so they run one at a time. */ + #gatt(start: () => Promise, write = false): Promise { + return new Promise((resolve, reject) => { + this.#queue.push({ start, resolve: resolve as (value: unknown) => void, reject, write }); + this.#drain(); + }); + } + + #drain(): void { + if (this.#running) return; + const next = this.#queue.shift(); + if (!next) return; + this.#running = next; + let operation: Promise; + try { operation = next.start(); } catch (error) { operation = Promise.reject(error); } + void operation.then(next.resolve, (error: unknown) => next.reject(error instanceof Error ? error : new Error('Bluetooth operation failed.'))).finally(() => { + if (this.#running === next) this.#running = null; + this.#drain(); + }); + } + + #bounded(operation: Promise, message: string, timeoutMs = this.timeoutMs): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (result: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + this.#timers.delete(cancel); + result(); + }; + const cancel = (error: Error) => finish(() => reject(error)); + const timer = setTimeout(() => cancel(new Error(message)), timeoutMs); + this.#timers.add(cancel); + void operation.then((value) => finish(() => resolve(value)), (error: unknown) => finish(() => reject(error instanceof Error ? error : new Error('Bluetooth operation failed.')))); + }); + } + + #cancelTimers(): void { + const error = new Error('Bluetooth operation was cancelled.'); + for (const cancel of [...this.#timers]) cancel(error); + } + + #requireSession(): Session { + if (!this.#session) throw new Error('No PC is connected.'); + return this.#session; + } + + #recordStage(stage: ConnectionStage, outcome: ConnectionStageOutcome, operation: number): void { + if (operation !== this.#operation) return; + try { this.diagnostics?.addConnectionStage(stage, outcome); } catch { /* best effort */ } + } + + async #stage(stage: ConnectionStage, action: () => Promise, operation: number): Promise { + this.#recordStage(stage, 'started', operation); + try { + const result = await action(); + this.#recordStage(stage, 'succeeded', operation); + return result; + } catch (error) { + this.#recordStage(stage, 'failed', operation); + throw error; + } + } +} diff --git a/src/transport/createBleTransport.ts b/src/transport/createBleTransport.ts new file mode 100644 index 0000000..91d3a26 --- /dev/null +++ b/src/transport/createBleTransport.ts @@ -0,0 +1,7 @@ +import type { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; +import type { BleTransport } from './BleTransport'; +import { ReactNativeBleTransport } from './ReactNativeBleTransport'; + +export function createBleTransport(diagnostics: Pick): BleTransport { + return new ReactNativeBleTransport(null, undefined, undefined, undefined, diagnostics); +} diff --git a/src/transport/createBleTransport.web.ts b/src/transport/createBleTransport.web.ts new file mode 100644 index 0000000..38832e3 --- /dev/null +++ b/src/transport/createBleTransport.web.ts @@ -0,0 +1,7 @@ +import type { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; +import type { BleTransport } from './BleTransport'; +import { navigatorBluetooth, WebBluetoothTransport } from './WebBluetoothTransport'; + +export function createBleTransport(diagnostics: Pick): BleTransport { + return new WebBluetoothTransport(navigatorBluetooth, undefined, diagnostics); +} From a09fa8fd2fa90387a6e24ea104d7583248e65816 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 7 Oct 2026 21:56:13 +0100 Subject: [PATCH 02/16] feat(web): let the web choose another PC and document web support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the stuck "Searching…" state with "Choose another PC" once the browser picker has returned a PC, since Web Bluetooth discovery does not keep scanning - Add docs/web.md covering supported browsers, how each platform layer differs, the fixed 182-byte write size and the hardware checks still needed - Link the web app from the README and protocol compatibility notes Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/protocol-compatibility.md | 2 +- docs/web.md | 42 ++++++++++++++++++++++++++++++++++ src/app/(tabs)/index.tsx | 7 +++--- src/connection/pcList.test.ts | 14 +++++++++++- src/connection/pcList.ts | 9 ++++++++ 6 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 docs/web.md diff --git a/README.md b/README.md index 0b21fbc..d086adb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Switchify Remote is the accessibility-first Android and iOS companion for controlling Switchify PC over Bluetooth. The development preview provides PC discovery and pairing plus Mouse, Typing, and Window control surfaces. -The app uses Expo SDK 57 with native Bluetooth support. It does **not** run in Expo Go. +The app uses Expo SDK 57 with native Bluetooth support. It does **not** run in Expo Go. It also runs as a [web app](docs/web.md) over Web Bluetooth in Chrome and Edge. ## Requirements diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index 47a6761..5ee938d 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -4,7 +4,7 @@ Switchify Remote is a protocol v1 client. It does not change the Bluetooth servi Custom section layouts use the separate `switchify.remote.layouts.v2` storage key with `{ version: 2, layouts: { [surface]: { [section]: { columns, cells } } } }`. Fixed section names are unchanged. All existing action IDs and v2 grids remain supported; action IDs are now validated against the shared catalog and surface placement rules instead of their original section. Draft actions are accepted only on Typing. An action may appear once in each section, including sections on different surfaces. Missing, malformed, oversized, unknown-version, duplicate-control, or unknown-action or forbidden-placement section data falls back independently to original section presentation. Version 1 flat layouts are deliberately ignored: their format cannot preserve section boundaries. Existing preference and pairing keys are unchanged, and old builds ignore the v2 key. Hidden capability/mode sections retain saved grids; visible controls continue to use current capability checks and dynamic labels. Records contain no command payloads, text, or authentication material. -The TypeScript compatibility suite carries the canonical authentication and pairing-code vectors from Switchify Android. Android requests the established 517-byte MTU, while both platforms adapt the inner frame payload so the encoded GATT value fits the negotiated ATT limit. Transport tests enforce the 160-byte maximum inner payload, 16 KiB message limit, 10-second partial timeout, duplicate and out-of-order handling, UTF-8 reassembly, response correlation, and sanitized failure behavior. +The TypeScript compatibility suite carries the canonical authentication and pairing-code vectors from Switchify Android. Android requests the established 517-byte MTU, while both platforms adapt the inner frame payload so the encoded GATT value fits the negotiated ATT limit. The [web app](web.md) cannot read the negotiated MTU, so it adapts frames to a fixed 182-byte write. Transport tests enforce the 160-byte maximum inner payload, 16 KiB message limit, 10-second partial timeout, duplicate and out-of-order handling, UTF-8 reassembly, response correlation, and sanitized failure behavior. The active preview surface supports: diff --git a/docs/web.md b/docs/web.md new file mode 100644 index 0000000..36a5d2d --- /dev/null +++ b/docs/web.md @@ -0,0 +1,42 @@ +# Web app + +Switchify Remote also runs as a web app built with Expo web. It uses the same screens, protocol client, pairing and layouts as the native apps. Only the platform layer is different. + +## Running + +```sh +npm run web # development server +npx expo export -p web # static site in dist/ +``` + +Web Bluetooth only works in a secure context, so serve the exported site over HTTPS. `http://localhost` also counts as secure for development. + +## Supported browsers + +Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and ChromeOS. Safari, Firefox and every iOS browser lack it. In those browsers the PCs screen shows that Bluetooth is unavailable and names the supported browsers. + +## How the web differs + +| Area | Native | Web | +|---|---|---| +| Transport | `ReactNativeBleTransport` (react-native-ble-plx) | `WebBluetoothTransport`, selected by `createBleTransport.web.ts` | +| Discovery | Background scan lists every nearby PC | The browser's device picker returns one chosen PC per search. Closing the picker returns to idle (`scan_cancelled`) without showing an error. | +| Saved PCs | Rescans and matches the desktop ID | Reuses devices this site may already use (`navigator.bluetooth.getDevices()`), matching by desktop ID. Otherwise it asks once through the picker, which needs a tap. | +| Write size | Negotiated MTU (Android requests 517) | Fixed at 182 bytes (a 185-byte ATT MTU). The browser hides the negotiated MTU, and Switchify PC on macOS rejects long (offset) writes. | +| GATT concurrency | Native transaction queue | One GATT operation at a time, because browsers reject overlapping operations | +| Write cancellation | Cancels the native transaction | An in-flight write cannot be aborted. Cancelling one makes writes unavailable until the next connection, matching the native behaviour when cancellation fails. | +| Pairing secrets | Keychain / Keystore via `expo-secure-store` | AES-GCM ciphertext in `localStorage`, encrypted with a non-extractable key held in IndexedDB (`secretStorage.web.ts`). Clearing site data removes the pairing. | +| Alerts | Native dialogs | `window.alert` and `window.confirm` (every app alert is a notice or a cancel plus one action) | +| Announcements | `AccessibilityInfo` | A polite ARIA live region (`installPlatform.web.ts`) | +| Diagnostics export | Share sheet | Text file download | +| Android bridge and Switch Forwarding | Android only | Unavailable | + +The protocol, framing limits, authentication and pairing approval are unchanged. See [protocol compatibility](protocol-compatibility.md). + +## Hardware validation still needed + +Automated tests use fake Web Bluetooth devices. Before release, check on real hardware: + +- pairing and control from Chrome on Android against Switchify PC on Windows and on macOS +- that 182-byte writes are accepted on each PC platform, and the reconnect behaviour after a PC restarts +- saved-PC reconnection after a browser restart, both with and without `getDevices()` support diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index febe855..9bfdae3 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import { Linking, View } from 'react-native'; +import { Linking, Platform, View } from 'react-native'; import { ActionButton } from '@/components/ActionButton'; import { AppText } from '@/components/AppText'; @@ -12,7 +12,7 @@ import { UnpairButton } from '@/components/UnpairButton'; import { useAccessibilityAnnouncement } from '@/components/useAccessibilityAnnouncement'; import { useConnectionManager, useConnectionState } from '@/connection/ConnectionContext'; import { bluetoothPermissionRecoveryMessage, bluetoothUnsupportedMessage } from '@/connection/permissions'; -import { mergePcList, pcListAction, type PcListItem } from '@/connection/pcList'; +import { discoveryButton, mergePcList, pcListAction, type PcListItem } from '@/connection/pcList'; import { shouldUseTwoColumns, useLayout, useTheme } from '@/theme/ThemeContext'; export default function PcsScreen() { @@ -24,6 +24,7 @@ export default function PcsScreen() { const saved = 'saved' in state ? state.saved : []; const discovered = state.kind === 'scanning' ? state.discovered : []; const pcs = mergePcList(saved, discovered); + const discovery = discoveryButton(state.kind === 'scanning', discovered.length, Platform.OS); const permissionRecovery = bluetoothPermissionRecoveryMessage(); const announcement = state.kind === 'scanning' ? 'Searching for nearby PCs.' : state.kind === 'connecting' ? `Connecting to ${state.desktop.displayName}.` : state.kind === 'reconnecting' ? `Reconnecting to ${state.desktop.displayName}, attempt ${state.attempt}.` : state.kind === 'pairing' ? `Approve the pairing request on your PC. Verification code ${state.verificationCode.split('').join(' ')}.` : state.kind === 'connected' ? `Connected to ${state.desktop.displayName}.` : state.kind === 'failed' ? `Connection failed. ${state.message}` : state.kind === 'permissionDenied' ? `Bluetooth permission needed. ${permissionRecovery}` : state.kind === 'bluetoothOff' ? 'Turn on Bluetooth, then search again.' : state.kind === 'unsupported' ? 'Bluetooth is unavailable on this device.' : null; useAccessibilityAnnouncement(announcement); @@ -33,7 +34,7 @@ export default function PcsScreen() { {state.kind === 'connected' ? {state.desktop.displayName} void manager.disconnect()} /> : null} {state.kind === 'connecting' || state.kind === 'reconnecting' ? {state.desktop.displayName}{state.kind === 'connecting' ? `Connecting to ${state.desktop.displayName}…` : `Reconnecting to ${state.desktop.displayName}…`} : null} {state.kind === 'failed' ? Could not connect{state.message} : null} - {state.kind !== 'connected' && state.kind !== 'pairing' && state.kind !== 'connecting' && state.kind !== 'reconnecting' ? void manager.scan()} /> : null} + {state.kind !== 'connected' && state.kind !== 'pairing' && state.kind !== 'connecting' && state.kind !== 'reconnecting' ? void manager.scan()} /> : null} {state.kind === 'permissionDenied' ? void Linking.openSettings()} />} /> : null} {state.kind === 'bluetoothOff' ? : null} {state.kind === 'unsupported' ? : null} diff --git a/src/connection/pcList.test.ts b/src/connection/pcList.test.ts index 0edf8b5..a3f230f 100644 --- a/src/connection/pcList.test.ts +++ b/src/connection/pcList.test.ts @@ -1,6 +1,6 @@ import type { SavedPc } from '@/storage/PairingStore'; import type { DiscoveredDesktop } from '@/transport/BleTransport'; -import { mergePcList, pcListAction } from './pcList'; +import { discoveryButton, mergePcList, pcListAction } from './pcList'; const saved = (desktopId: string, displayName = desktopId): SavedPc => ({ desktopId, displayName, platform: 'windows', peripheralId: `old-${desktopId}`, lastConnectedAt: 1 }); const nearby = (desktopId: string, displayName = desktopId): DiscoveredDesktop => ({ desktopId, displayName, platform: 'windows', peripheralId: `new-${desktopId}`, rssi: -40 }); @@ -22,3 +22,15 @@ describe('PC list identity', () => { expect(rows.map(pcListAction)).toEqual(['Connect', 'Request access', 'Request access']); }); }); + +describe('discovery button', () => { + it('waits while native discovery runs', () => { + expect(discoveryButton(true, 2, 'android')).toEqual({ label: 'Searching…', busy: true, disabled: true }); + expect(discoveryButton(false, 0, 'ios')).toEqual({ label: 'Find nearby PCs', busy: false, disabled: false }); + }); + + it('lets the web choose another PC once the browser picker returns one', () => { + expect(discoveryButton(true, 0, 'web')).toMatchObject({ busy: true, disabled: true }); + expect(discoveryButton(true, 1, 'web')).toEqual({ label: 'Choose another PC', busy: false, disabled: false }); + }); +}); diff --git a/src/connection/pcList.ts b/src/connection/pcList.ts index 3a00de1..26e1e1d 100644 --- a/src/connection/pcList.ts +++ b/src/connection/pcList.ts @@ -19,3 +19,12 @@ export function mergePcList(saved: SavedPc[], discovered: DiscoveredDesktop[]): export function pcListAction(pc: PcListItem): 'Connect' | 'Request access' { return pc.saved ? 'Connect' : 'Request access'; } + +export type DiscoveryButton = { label: string; busy: boolean; disabled: boolean }; + +/** A browser picker returns one PC and then stops, so the web offers to choose another instead of waiting. */ +export function discoveryButton(scanning: boolean, discoveredCount: number, platform: string): DiscoveryButton { + if (!scanning) return { label: 'Find nearby PCs', busy: false, disabled: false }; + if (platform === 'web' && discoveredCount > 0) return { label: 'Choose another PC', busy: false, disabled: false }; + return { label: 'Searching…', busy: true, disabled: true }; +} From 896c094de475b4f188e5b2bbe2971ac83fdc22c2 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 7 Oct 2026 22:08:14 +0100 Subject: [PATCH 03/16] fix(web): harden Web Bluetooth lifecycle and web secret storage - Wait briefly for an in-flight write when cancelling instead of poisoning writes, so disconnect cleanup (drag end, modifier up) still reaches the PC - Give the GATT queue a generation so a stuck operation cannot block later connections, and track which attempt owns each device connection so stale or abandoned probes close late connections without closing newer ones - Bound remembered-device checks to 3 seconds so the picker still opens inside the browser's tap window, and fail discovery when a chosen device reports an invalid status - Let web secret storage surface key-load failures instead of treating them as missing secrets, resolve first-key races between tabs, and treat only failed authentication as an absent value - Create the live region at startup and name the action in confirm dialogs - Cover the web transport, secret storage, platform shims, diagnostics export and reduced transparency with fake-device and jsdom tests Co-Authored-By: Claude Opus 5.5 --- src/diagnostics/exportDiagnostics.web.test.ts | 29 +++ src/platform/installPlatform.web.test.ts | 49 ++++ src/platform/installPlatform.web.ts | 9 +- src/storage/secretStorage.web.test.ts | 111 +++++++++ src/storage/secretStorage.web.ts | 58 +++-- src/theme/reducedTransparency.web.test.ts | 22 ++ src/transport/WebBluetoothTransport.test.ts | 80 ++++++- src/transport/WebBluetoothTransport.ts | 210 +++++++++++++----- 8 files changed, 492 insertions(+), 76 deletions(-) create mode 100644 src/diagnostics/exportDiagnostics.web.test.ts create mode 100644 src/platform/installPlatform.web.test.ts create mode 100644 src/storage/secretStorage.web.test.ts create mode 100644 src/theme/reducedTransparency.web.test.ts diff --git a/src/diagnostics/exportDiagnostics.web.test.ts b/src/diagnostics/exportDiagnostics.web.test.ts new file mode 100644 index 0000000..4787599 --- /dev/null +++ b/src/diagnostics/exportDiagnostics.web.test.ts @@ -0,0 +1,29 @@ +/** @jest-environment jsdom */ +import { exportDiagnostics } from './exportDiagnostics.web'; + +const nodeText = (globalThis as unknown as { process: { getBuiltinModule(id: string): unknown } }).process.getBuiltinModule('node:util') as { TextEncoder: typeof TextEncoder; TextDecoder: typeof TextDecoder }; +Object.assign(globalThis, { TextEncoder: nodeText.TextEncoder, TextDecoder: nodeText.TextDecoder }); + +describe('web diagnostics export', () => { + it('downloads the sanitized log as a text file and releases the object URL', async () => { + jest.useFakeTimers(); + try { + const created: Blob[] = []; + Object.assign(URL, { + createObjectURL: jest.fn((blob: Blob) => { created.push(blob); return 'blob:diagnostics'; }), + revokeObjectURL: jest.fn(), + }); + let downloaded = ''; + const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloaded = this.download; }); + await exportDiagnostics('connected: Connected to a PC.'); + expect(click).toHaveBeenCalledTimes(1); + expect(downloaded).toBe('switchify-remote-diagnostics.txt'); + expect(created[0]?.type).toBe('text/plain'); + expect(document.querySelector('a')).toBeNull(); + jest.runAllTimers(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:diagnostics'); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/src/platform/installPlatform.web.test.ts b/src/platform/installPlatform.web.test.ts new file mode 100644 index 0000000..2809517 --- /dev/null +++ b/src/platform/installPlatform.web.test.ts @@ -0,0 +1,49 @@ +/** @jest-environment jsdom */ +import { AccessibilityInfo, Alert } from 'react-native'; + +import { installPlatform } from './installPlatform.web'; + +describe('web platform shims', () => { + beforeAll(() => installPlatform()); + + it('creates the live region before the first announcement', () => { + const region = document.getElementById('switchify-live-region'); + expect(region?.getAttribute('aria-live')).toBe('polite'); + expect(region?.getAttribute('role')).toBe('status'); + }); + + it('announces queued messages one after another through the live region', () => { + jest.useFakeTimers(); + try { + const region = document.getElementById('switchify-live-region')!; + AccessibilityInfo.announceForAccessibility('Connecting.'); + (AccessibilityInfo as unknown as { announceForAccessibilityWithOptions: (message: string, options: object) => void }) + .announceForAccessibilityWithOptions('Connected.', { queue: true }); + jest.advanceTimersByTime(60); + expect(region.textContent).toBe('Connecting.'); + jest.advanceTimersByTime(600); + expect(region.textContent).toBe('Connected.'); + } finally { + jest.useRealTimers(); + } + }); + + it('shows notices with the browser alert', () => { + const alert = jest.spyOn(window, 'alert').mockImplementation(() => undefined); + Alert.alert('Setup could not continue', 'Try again.'); + expect(alert).toHaveBeenCalledWith('Setup could not continue\n\nTry again.'); + }); + + it('maps a cancel and an action onto a confirm that names the action', () => { + const unpair = jest.fn(); + const keep = jest.fn(); + const confirm = jest.spyOn(window, 'confirm').mockReturnValueOnce(true).mockReturnValueOnce(false); + const buttons = [{ text: 'Cancel', style: 'cancel' as const, onPress: keep }, { text: 'Unpair', style: 'destructive' as const, onPress: unpair }]; + Alert.alert('Unpair Office?', 'This removes saved access.', buttons); + expect(confirm).toHaveBeenLastCalledWith('Unpair Office?\n\nThis removes saved access.\n\nChoose OK to unpair.'); + expect(unpair).toHaveBeenCalledTimes(1); + Alert.alert('Unpair Office?', 'This removes saved access.', buttons); + expect(keep).toHaveBeenCalledTimes(1); + expect(unpair).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/platform/installPlatform.web.ts b/src/platform/installPlatform.web.ts index af97d46..3049d03 100644 --- a/src/platform/installPlatform.web.ts +++ b/src/platform/installPlatform.web.ts @@ -68,7 +68,9 @@ const alert: typeof Alert.alert = (title, message, buttons) => { } const cancel = choices.find((button) => button.style === 'cancel') ?? choices[0]; const action = choices.find((button) => button !== cancel); - if (window.confirm(text)) action?.onPress?.(); + // Browser dialogs only offer OK and Cancel, so the action is named in the text. + const prompt = action?.text ? `${text}\n\nChoose OK to ${action.text.toLowerCase()}.` : text; + if (window.confirm(prompt)) action?.onPress?.(); else cancel?.onPress?.(); }; @@ -82,4 +84,9 @@ export function installPlatform(): void { }; info.announceForAccessibility = announce; info.announceForAccessibilityWithOptions = (message) => announce(message); + // Some screen readers ignore a live region that appears just before its first update. + if (typeof document !== 'undefined') { + if (document.body) liveRegion(); + else document.addEventListener('DOMContentLoaded', () => liveRegion(), { once: true }); + } } diff --git a/src/storage/secretStorage.web.test.ts b/src/storage/secretStorage.web.test.ts new file mode 100644 index 0000000..3178671 --- /dev/null +++ b/src/storage/secretStorage.web.test.ts @@ -0,0 +1,111 @@ +/** @jest-environment jsdom */ +const builtin = (globalThis as unknown as { process: { getBuiltinModule(id: string): unknown } }).process.getBuiltinModule; +const { webcrypto } = builtin('node:crypto') as { webcrypto: Crypto }; +const nodeText = builtin('node:util') as { TextEncoder: typeof TextEncoder; TextDecoder: typeof TextDecoder }; + +Object.assign(globalThis, { TextEncoder: nodeText.TextEncoder, TextDecoder: nodeText.TextDecoder }); + +type Request = { result: T; error: Error | null; onsuccess: (() => void) | null; onerror: (() => void) | null; onupgradeneeded?: (() => void) | null }; + +class FakeIndexedDb { + stores = new Map>(); + failOpen = false; + addGate: Promise | null = null; + open(): Request { + const request: Request = { result: null, error: null, onsuccess: null, onerror: null, onupgradeneeded: null }; + setTimeout(() => { + if (this.failOpen) { request.error = new Error('blocked'); request.onerror?.(); return; } + const database = { + createObjectStore: (name: string) => { this.stores.set(name, new Map()); }, + transaction: (name: string) => ({ objectStore: () => this.#store(name) }), + close: () => undefined, + }; + request.result = database; + if (!this.stores.has('keys')) request.onupgradeneeded?.(); + request.onsuccess?.(); + }, 0); + return request; + } + #store(name: string) { + const store = this.stores.get(name)!; + const respond = (action: () => T, gate: Promise | null = null): Request => { + const request: Request = { result: undefined, error: null, onsuccess: null, onerror: null }; + void Promise.resolve(gate).then(() => setTimeout(() => { + try { request.result = action(); request.onsuccess?.(); } + catch (error) { request.error = error as Error; request.onerror?.(); } + }, 0)); + return request; + }; + return { + get: (key: string) => respond(() => store.get(key)), + add: (value: unknown, key: string) => respond(() => { + if (store.has(key)) throw Object.assign(new Error('exists'), { name: 'ConstraintError' }); + store.set(key, value); + }, this.addGate), + }; + } +} + +let indexedDb: FakeIndexedDb; + +function load(): typeof import('./secretStorage.web') { + let loaded!: typeof import('./secretStorage.web'); + jest.isolateModules(() => { loaded = jest.requireActual('./secretStorage.web'); }); + return loaded; +} + +beforeEach(() => { + indexedDb = new FakeIndexedDb(); + window.localStorage.clear(); + Object.defineProperty(window, 'indexedDB', { configurable: true, value: indexedDb }); + Object.defineProperty(window, 'crypto', { configurable: true, value: webcrypto }); + Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true }); +}); + +describe('web secret storage', () => { + it('round-trips a secret without storing it in plain text', async () => { + const { secretStorage } = load(); + await secretStorage.setItemAsync('switchify.remote.token.pc-1', 'secret-token'); + const stored = Object.values({ ...window.localStorage }).join(' '); + expect(stored).not.toContain('secret-token'); + expect(await secretStorage.getItemAsync('switchify.remote.token.pc-1')).toBe('secret-token'); + await secretStorage.deleteItemAsync('switchify.remote.token.pc-1'); + expect(await secretStorage.getItemAsync('switchify.remote.token.pc-1')).toBeNull(); + }); + + it('reports a key that cannot be loaded as an error, not as a missing secret', async () => { + await load().secretStorage.setItemAsync('token', 'secret-token'); + indexedDb.failOpen = true; + await expect(load().secretStorage.getItemAsync('token')).rejects.toThrow(); + }); + + it('treats a value that fails authentication as absent', async () => { + const { secretStorage } = load(); + await secretStorage.setItemAsync('token', 'secret-token'); + const key = 'switchify.remote.secret.v1.token'; + const [iv, data] = window.localStorage.getItem(key)!.split('.'); + window.localStorage.setItem(key, `${iv}.${data!.slice(0, -4)}AAA=`); + expect(await secretStorage.getItemAsync('token')).toBeNull(); + window.localStorage.setItem('switchify.remote.secret.v1.moved', window.localStorage.getItem(key)!); + expect(await secretStorage.getItemAsync('moved')).toBeNull(); + }); + + it('shares one key when two tabs create it at the same time', async () => { + let release!: () => void; + indexedDb.addGate = new Promise((resolve) => { release = resolve; }); + const first = load().secretStorage; + const second = load().secretStorage; + const writes = Promise.all([first.setItemAsync('a', 'one'), second.setItemAsync('b', 'two')]); + await new Promise((resolve) => setTimeout(resolve, 20)); + release(); + await writes; + const reader = load().secretStorage; + expect(await reader.getItemAsync('a')).toBe('one'); + expect(await reader.getItemAsync('b')).toBe('two'); + }); + + it('refuses to run outside a secure context', async () => { + Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false }); + await expect(load().secretStorage.getItemAsync('token')).rejects.toThrow('unavailable'); + }); +}); diff --git a/src/storage/secretStorage.web.ts b/src/storage/secretStorage.web.ts index 912751b..541fc3b 100644 --- a/src/storage/secretStorage.web.ts +++ b/src/storage/secretStorage.web.ts @@ -9,16 +9,18 @@ const VALUE_PREFIX = 'switchify.remote.secret.v1.'; let keyPromise: Promise | null = null; +function unavailable(): Error { + return new Error('Secure storage is unavailable in this browser.'); +} + function requireSecureContext(): void { - if (typeof window === 'undefined' || !window.isSecureContext || !window.crypto?.subtle || !window.indexedDB) { - throw new Error('Secure storage is unavailable in this browser.'); - } + if (typeof window === 'undefined' || !window.isSecureContext || !window.crypto?.subtle || !window.indexedDB) throw unavailable(); } function request(operation: IDBRequest): Promise { return new Promise((resolve, reject) => { operation.onsuccess = () => resolve(operation.result); - operation.onerror = () => reject(new Error('Secure storage is unavailable in this browser.')); + operation.onerror = () => reject(operation.error ?? unavailable()); }); } @@ -28,19 +30,27 @@ async function openDatabase(): Promise { return await request(open); } +async function storedKey(database: IDBDatabase): Promise { + return await request(database.transaction(STORE, 'readonly').objectStore(STORE).get(KEY_ID)) as CryptoKey | undefined; +} + /** A non-extractable key: the page can use it, but it cannot be read out of the browser. */ async function loadKey(): Promise { requireSecureContext(); const database = await openDatabase(); try { - const existing = await request(database.transaction(STORE, 'readonly').objectStore(STORE).get(KEY_ID)) as CryptoKey | undefined; + const existing = await storedKey(database); if (existing) return existing; const created = await window.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); - const store = database.transaction(STORE, 'readwrite').objectStore(STORE); - const raced = await request(store.get(KEY_ID)) as CryptoKey | undefined; - if (raced) return raced; - await request(store.add(created, KEY_ID)); - return created; + try { + await request(database.transaction(STORE, 'readwrite').objectStore(STORE).add(created, KEY_ID)); + return created; + } catch (error) { + // Another tab stored its key first; every tab must use the same one. + const raced = await storedKey(database); + if (raced) return raced; + throw error; + } } finally { database.close(); } @@ -67,18 +77,34 @@ function fromBase64(value: string): Uint8Array { return bytes; } +function parse(stored: string): { iv: Uint8Array; data: Uint8Array } | null { + const [iv, data, extra] = stored.split('.'); + if (!iv || !data || extra !== undefined) return null; + try { + return { iv: fromBase64(iv), data: fromBase64(data) }; + } catch { + return null; + } +} + +/** + * Only a value that fails authentication under the current key is treated as absent. + * A key that cannot be loaded is an error, so a temporary storage failure never looks + * like missing pairings. + */ async function getItemAsync(name: string): Promise { requireSecureContext(); const stored = window.localStorage.getItem(`${VALUE_PREFIX}${name}`); if (stored === null) return null; - const [iv, data] = stored.split('.'); - if (!iv || !data) return null; + const parsed = parse(stored); + if (!parsed) return null; + const secret = await key(); try { - const plain = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromBase64(iv), additionalData: new TextEncoder().encode(name) }, await key(), fromBase64(data)); + const plain = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: parsed.iv, additionalData: new TextEncoder().encode(name) }, secret, parsed.data); return new TextDecoder().decode(plain); - } catch { - // A value the current key cannot open is unrecoverable, so it is treated as absent. - return null; + } catch (error) { + if (typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'OperationError') return null; + throw error; } } diff --git a/src/theme/reducedTransparency.web.test.ts b/src/theme/reducedTransparency.web.test.ts new file mode 100644 index 0000000..fb278f1 --- /dev/null +++ b/src/theme/reducedTransparency.web.test.ts @@ -0,0 +1,22 @@ +/** @jest-environment jsdom */ +import { subscribeReducedTransparency } from './reducedTransparency.web'; + +describe('web reduced transparency', () => { + it('follows the prefers-reduced-transparency media query until unsubscribed', () => { + const listeners = new Set<(event: MediaQueryListEvent) => void>(); + const query = { + matches: true, + addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener), + removeEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener), + }; + Object.defineProperty(window, 'matchMedia', { configurable: true, value: jest.fn(() => query) }); + const onChange = jest.fn(); + const unsubscribe = subscribeReducedTransparency(onChange); + expect(window.matchMedia).toHaveBeenCalledWith('(prefers-reduced-transparency: reduce)'); + expect(onChange).toHaveBeenLastCalledWith(true); + listeners.forEach((listener) => listener({ matches: false } as MediaQueryListEvent)); + expect(onChange).toHaveBeenLastCalledWith(false); + unsubscribe(); + expect(listeners.size).toBe(0); + }); +}); diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index 5717e45..c5c4cbf 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -3,6 +3,7 @@ import { fromByteArray, toByteArray } from 'base64-js'; import { BLE_UUIDS } from '@/domain/protocol/constants'; import { BluetoothDeviceSelectionCancelledError } from './BleTransport'; import { + REMEMBERED_DEVICE_BUDGET_MS, WEB_MAX_WRITE_VALUE_BYTES, WebBluetoothTransport, type WebBluetooth, @@ -45,6 +46,7 @@ class FakePeripheral extends EventTarget implements WebBluetoothDevice, WebBluet overlapping = false; connects = 0; writeGate: Promise | null = null; + connectGate: Promise | null = null; characteristics: Record; constructor(readonly id: string, public status: string, readonly name: string | null = 'Office PC', withResponse = false) { super(); @@ -52,7 +54,7 @@ class FakePeripheral extends EventTarget implements WebBluetoothDevice, WebBluet this.characteristics = Object.fromEntries(uuids.map((uuid) => [uuid, new FakeCharacteristic(this, uuid)])); } get gatt(): WebBluetoothServer { return this; } - async connect(): Promise { this.connects += 1; this.connected = true; return this; } + async connect(): Promise { this.connects += 1; await this.connectGate; this.connected = true; return this; } disconnect(): void { if (!this.connected) return; this.connected = false; @@ -95,7 +97,7 @@ class FakeBluetooth implements WebBluetooth { } } -const flush = async () => { for (let index = 0; index < 20; index += 1) await Promise.resolve(); }; +const flush = async () => { for (let index = 0; index < 100; index += 1) await Promise.resolve(); }; describe('WebBluetoothTransport', () => { it('reports when the browser has no Web Bluetooth', async () => { @@ -227,7 +229,7 @@ describe('WebBluetoothTransport', () => { expect(await transport.verifyConnection('desk-1')).toBe(false); }); - it('requires a reconnect after cancelling a write already in flight', async () => { + it('waits for an in-flight write when cancelling so cleanup commands still reach the PC', async () => { const bluetooth = new FakeBluetooth(); const peripheral = new FakePeripheral('web-1', status('desk-1')); bluetooth.chooser = async () => peripheral; @@ -238,10 +240,78 @@ describe('WebBluetoothTransport', () => { const inFlight = transport.writeFrame('YQ=='); const queued = transport.writeFrame('Yg=='); await flush(); - await transport.cancelPendingWrites(); + const cancelling = transport.cancelPendingWrites(); await expect(queued).rejects.toThrow('cancelled'); release(); await inFlight; - await expect(transport.writeFrame('Yw==')).rejects.toThrow('until reconnect'); + await cancelling; + peripheral.writeGate = null; + await transport.writeFrame('Yw=='); + expect(peripheral.characteristics[BLE_UUIDS.receive]!.writes.map((bytes) => fromByteArray(bytes))).toEqual(['YQ==', 'Yw==']); + }); + + it('requires a reconnect only when an in-flight write never settles, and a new connection is not blocked by it', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth, 30); + await transport.resolveAndConnect('desk-1'); + peripheral.writeGate = new Promise(() => undefined); + const stuck = transport.writeFrame('YQ=='); + void stuck.catch(() => undefined); + await flush(); + await transport.cancelPendingWrites(); + await expect(transport.writeFrame('Yg==')).rejects.toThrow('until reconnect'); + peripheral.writeGate = null; + peripheral.busy = false; + await transport.resolveAndConnect('desk-1'); + await expect(transport.writeFrame('Yw==')).resolves.toBeUndefined(); + }); + + it('reports a device with an invalid status instead of waiting forever', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', 'not json'); + bluetooth.chooser = async () => peripheral; + const onError = jest.fn(); + new WebBluetoothTransport(() => bluetooth).scan(jest.fn(), onError); + await flush(); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'Bluetooth discovery failed.' })); + expect(peripheral.connected).toBe(false); + }); + + it('closes a discovery connection that completes after the search stopped', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + let release!: () => void; + peripheral.connectGate = new Promise((resolve) => { release = resolve; }); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const found = jest.fn(); + const stop = transport.scan(found, jest.fn()); + await flush(); + stop(); + release(); + await flush(); + expect(peripheral.connected).toBe(false); + expect(found).not.toHaveBeenCalled(); + }); + + it('stops checking unreachable remembered devices in time to open the picker', async () => { + jest.useFakeTimers(); + try { + const bluetooth = new FakeBluetooth(); + const unreachable = new FakePeripheral('web-0', status('desk-0')); + unreachable.connectGate = new Promise(() => undefined); + const chosen = new FakePeripheral('web-1', status('desk-1')); + bluetooth.remembered = [unreachable]; + bluetooth.chooser = async () => chosen; + const transport = new WebBluetoothTransport(() => bluetooth); + const connecting = transport.resolveAndConnect('desk-1'); + await jest.advanceTimersByTimeAsync(REMEMBERED_DEVICE_BUDGET_MS + 10); + await expect(connecting).resolves.toMatchObject({ desktopId: 'desk-1' }); + expect(bluetooth.requests).toBe(1); + } finally { + jest.useRealTimers(); + } }); }); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index 879bc07..7f62beb 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -14,6 +14,12 @@ import { ReadResponsePoller } from './ReadResponsePoller'; */ export const WEB_MAX_WRITE_VALUE_BYTES = 182; +/** + * Browsers only open the device picker within a few seconds of a tap, so checking + * remembered devices must finish well inside that window. + */ +export const REMEMBERED_DEVICE_BUDGET_MS = 3_000; + export interface WebBluetoothCharacteristic extends EventTarget { readonly value?: DataView | null; readValue(): Promise; @@ -54,7 +60,7 @@ type Characteristics = { type Session = { device: WebBluetoothDevice; server: WebBluetoothServer; characteristics: Characteristics; readReplies: boolean }; -type Pending = { start: () => Promise; resolve: (value: unknown) => void; reject: (error: Error) => void; write: boolean }; +type Pending = { start: () => Promise; resolve: (value: unknown) => void; reject: (error: Error) => void; write: boolean; done: Promise }; export function navigatorBluetooth(): WebBluetooth | null { if (typeof navigator === 'undefined') return null; @@ -73,14 +79,21 @@ function isCancellation(error: unknown): boolean { return error instanceof Error && error.name === 'NotFoundError'; } +function cancelled(): Error { + return new Error('Bluetooth connection was cancelled.'); +} + export class WebBluetoothTransport implements BleTransport { #operation = 0; #session: Session | null = null; #devices = new Map(); #desktopDevices = new Map(); - #probes = new Set(); + /** The operation allowed to close each device's connection, so a stale attempt never closes a newer one. */ + #owners = new Map(); + #probes = new Map(); #queue: Pending[] = []; #running: Pending | null = null; + #queueGeneration = 0; #writePoisoned = false; #responsePoller: ReadResponsePoller | null = null; #notificationsReady: Promise | null = null; @@ -90,6 +103,7 @@ export class WebBluetoothTransport implements BleTransport { private readonly bluetooth: () => WebBluetooth | null = navigatorBluetooth, private readonly timeoutMs = 10_000, private readonly diagnostics?: Pick, + private readonly now: () => number = Date.now, ) {} async availability(): Promise { @@ -113,10 +127,10 @@ export class WebBluetoothTransport implements BleTransport { const device = await bluetooth.requestDevice({ filters: [{ services: [BLE_UUIDS.service] }] }); if (!active || operation !== this.#operation) return; probe = device; - this.#probes.add(device); const desktop = await this.#readDesktop(device, operation); if (!active || operation !== this.#operation) return; - if (desktop) onDesktop(desktop); + if (!desktop) throw new Error('Bluetooth discovery status is invalid.'); + onDesktop(desktop); })().catch((error: unknown) => { if (!active || operation !== this.#operation) return; onError(isCancellation(error) ? new BluetoothDeviceSelectionCancelledError() : new Error('Bluetooth discovery failed.')); @@ -125,14 +139,19 @@ export class WebBluetoothTransport implements BleTransport { if (!active) return; active = false; if (operation === this.#operation) this.#operation += 1; - if (probe) this.#releaseProbe(probe); + if (probe) this.#releaseProbe(probe, operation); }; } async connect(peripheralId: string): Promise { await this.disconnect(); const operation = ++this.#operation; - const device = this.#devices.get(peripheralId) ?? await this.#rememberedDevice((candidate) => candidate.id === peripheralId); + let device = this.#devices.get(peripheralId) ?? null; + if (!device) { + const remembered = await this.#rememberedDevices(); + device = remembered.find((candidate) => candidate.id === peripheralId) ?? null; + } + if (operation !== this.#operation) throw cancelled(); if (!device) throw new Error('This PC is not available to the browser. Choose it again.'); await this.#open(device, operation); } @@ -144,15 +163,15 @@ export class WebBluetoothTransport implements BleTransport { try { const known = this.#desktopDevices.get(desktopId); let device = known ? this.#devices.get(known) ?? null : null; - device ??= await this.#rememberedDevice(() => true, desktopId, operation); - if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); + device ??= await this.#findRememberedDesktop(desktopId, operation); + if (operation !== this.#operation) throw cancelled(); if (!device) { const bluetooth = this.bluetooth(); if (!bluetooth) throw new Error('Web Bluetooth is unavailable.'); device = await bluetooth.requestDevice({ filters: [{ services: [BLE_UUIDS.service] }] }); this.#devices.set(device.id, device); } - if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); + if (operation !== this.#operation) throw cancelled(); const status = await this.#open(device, operation); this.#recordStage('selected_match', status.desktopId === desktopId ? 'succeeded' : 'not_matched', operation); if (status.desktopId !== desktopId) throw new Error('The chosen device is a different PC.'); @@ -172,12 +191,16 @@ export class WebBluetoothTransport implements BleTransport { this.#notificationsReady = null; this.#cancelTimers(); await this.cancelPendingWrites(); + this.#resetQueue(); this.#writePoisoned = false; - for (const probe of [...this.#probes]) this.#releaseProbe(probe); + for (const [probe, owner] of [...this.#probes]) this.#releaseProbe(probe, owner); const session = this.#session; this.#session = null; - if (session?.server.connected) { - try { session.server.disconnect(); } catch { /* Already disconnected. */ } + if (session) { + this.#owners.delete(session.device.id); + if (session.server.connected) { + try { session.server.disconnect(); } catch { /* Already disconnected. */ } + } } } @@ -194,13 +217,25 @@ export class WebBluetoothTransport implements BleTransport { await this.#bounded(this.#gatt(() => session.characteristics.receive.writeValueWithResponse(bytes), true), 'Bluetooth write timed out.'); } + /** + * Drops queued writes. Web Bluetooth cannot abort a write already sent, so this + * waits briefly for it; only a write that never settles makes writes unavailable + * until reconnect. Cleanup commands sent after cancelling therefore still reach the PC. + */ async cancelPendingWrites(): Promise { const error = new Error('Bluetooth write was cancelled.'); const queued = this.#queue.filter((item) => item.write); this.#queue = this.#queue.filter((item) => !item.write); queued.forEach((item) => item.reject(error)); - // Web Bluetooth cannot abort a write already sent to the PC. - if (this.#running?.write) this.#writePoisoned = true; + const running = this.#running; + if (!running?.write) return; + let timer: ReturnType | undefined; + const settled = await Promise.race([ + running.done.then(() => true), + new Promise((resolve) => { timer = setTimeout(() => resolve(false), this.#cancellationTimeout()); }), + ]); + clearTimeout(timer); + if (!settled && this.#running === running) this.#writePoisoned = true; } async verifyConnection(desktopId: string): Promise { @@ -271,17 +306,15 @@ export class WebBluetoothTransport implements BleTransport { async #open(device: WebBluetoothDevice, operation: number): Promise { this.#devices.set(device.id, device); this.#probes.delete(device); - const gatt = device.gatt; - if (!gatt) throw new Error('This device does not support Bluetooth connections.'); - const server = await this.#stage('connect', () => this.#bounded(gatt.connect(), 'Bluetooth operation timed out.'), operation); - if (operation !== this.#operation) { server.disconnect(); throw new Error('Bluetooth connection was cancelled.'); } + this.#owners.set(device.id, operation); try { + const server = await this.#connectGatt(device, operation, 'connect'); const characteristics = await this.#stage('services', () => this.#bounded(this.#characteristics(server), 'Bluetooth operation timed out.'), operation); - if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); + if (operation !== this.#operation) throw cancelled(); const view = await this.#stage('status_read', () => this.#bounded(this.#gatt(() => characteristics.status.readValue()), 'Bluetooth operation timed out.'), operation); + if (operation !== this.#operation) throw cancelled(); const status = decodeStatus(view); if (!status) throw new Error('Bluetooth discovery status is invalid.'); - if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); const readReplies = status.responseTransport === 'read-v1'; if (readReplies && !characteristics.response) throw new Error('Bluetooth discovery status is invalid.'); this.#desktopDevices.set(status.desktopId, device.id); @@ -289,7 +322,26 @@ export class WebBluetoothTransport implements BleTransport { this.#writePoisoned = false; return status; } catch (error) { - try { server.disconnect(); } catch { /* Already disconnected. */ } + this.#closeIfOwner(device, operation); + throw error; + } + } + + /** Connects, and closes the connection if it only completes after the attempt was abandoned. */ + async #connectGatt(device: WebBluetoothDevice, operation: number, stage: ConnectionStage, timeoutMs = this.timeoutMs): Promise { + const gatt = device.gatt; + if (!gatt) throw new Error('This device does not support Bluetooth connections.'); + let abandoned = false; + const connecting = gatt.connect(); + void connecting.then(() => { + if (abandoned || operation !== this.#operation) this.#closeIfOwner(device, operation); + }, () => undefined); + try { + const server = await this.#stage(stage, () => this.#bounded(connecting, 'Bluetooth operation timed out.', timeoutMs), operation); + if (operation !== this.#operation) throw cancelled(); + return server; + } catch (error) { + abandoned = true; throw error; } } @@ -305,42 +357,63 @@ export class WebBluetoothTransport implements BleTransport { return { receive, transmit, status, response }; } - async #readDesktop(device: WebBluetoothDevice, operation: number): Promise { - const gatt = device.gatt; - if (!gatt) return null; - const server = await this.#stage('probe_connect', () => this.#bounded(gatt.connect(), 'Bluetooth operation timed out.'), operation); - const service = await this.#stage('probe_services', () => this.#bounded(server.getPrimaryService(BLE_UUIDS.service), 'Bluetooth operation timed out.'), operation); - const characteristic = await this.#bounded(service.getCharacteristic(BLE_UUIDS.status), 'Bluetooth operation timed out.'); - const view = await this.#stage('status_read', () => this.#bounded(this.#gatt(() => characteristic.readValue()), 'Bluetooth operation timed out.'), operation); - this.#recordStage('status_parse', 'started', operation); - const status = decodeStatus(view); - this.#recordStage('status_parse', status ? 'succeeded' : 'failed', operation); - if (!status) return null; - this.#devices.set(device.id, device); - this.#desktopDevices.set(status.desktopId, device.id); - return this.#describe(device, status); + /** Reads a device's status over a short-lived probe connection owned by `operation`. */ + async #readDesktop(device: WebBluetoothDevice, operation: number, timeoutMs = this.timeoutMs): Promise { + this.#probes.set(device, operation); + this.#owners.set(device.id, operation); + const deadline = this.now() + timeoutMs; + const remaining = () => Math.max(1, deadline - this.now()); + try { + const server = await this.#connectGatt(device, operation, 'probe_connect', remaining()); + const service = await this.#stage('probe_services', () => this.#bounded(server.getPrimaryService(BLE_UUIDS.service), 'Bluetooth operation timed out.', remaining()), operation); + if (operation !== this.#operation) throw cancelled(); + const characteristic = await this.#bounded(service.getCharacteristic(BLE_UUIDS.status), 'Bluetooth operation timed out.', remaining()); + if (operation !== this.#operation) throw cancelled(); + const view = await this.#stage('status_read', () => this.#bounded(this.#gatt(() => characteristic.readValue()), 'Bluetooth operation timed out.', remaining()), operation); + if (operation !== this.#operation) throw cancelled(); + this.#recordStage('status_parse', 'started', operation); + const status = decodeStatus(view); + this.#recordStage('status_parse', status ? 'succeeded' : 'failed', operation); + if (!status) { + this.#releaseProbe(device, operation); + return null; + } + this.#devices.set(device.id, device); + this.#desktopDevices.set(status.desktopId, device.id); + return this.#describe(device, status); + } catch (error) { + this.#releaseProbe(device, operation); + throw error; + } } - /** Devices this site was allowed before, where the browser supports remembering them. */ - async #rememberedDevice(matches: (device: WebBluetoothDevice) => boolean, desktopId?: string, operation = this.#operation): Promise { + async #rememberedDevices(): Promise { const bluetooth = this.bluetooth(); - if (!bluetooth?.getDevices) return null; - let devices: WebBluetoothDevice[]; - try { devices = (await bluetooth.getDevices()).filter(matches); } catch { return null; } - for (const device of devices) { - this.#devices.set(device.id, device); - if (desktopId === undefined) return device; - if (this.#desktopDevices.get(desktopId) === device.id) return device; + if (!bluetooth?.getDevices) return []; + try { + const devices = await bluetooth.getDevices(); + devices.forEach((device) => this.#devices.set(device.id, device)); + return devices; + } catch { + return []; } - if (desktopId === undefined) return null; + } + + /** Looks for a saved PC among devices this site may already use, within the picker's tap window. */ + async #findRememberedDesktop(desktopId: string, operation: number): Promise { + const deadline = this.now() + REMEMBERED_DEVICE_BUDGET_MS; + const devices = await this.#rememberedDevices(); + const mapped = devices.find((device) => this.#desktopDevices.get(desktopId) === device.id); + if (mapped) return mapped; for (const device of devices) { - if (operation !== this.#operation) return null; + const remaining = deadline - this.now(); + if (operation !== this.#operation || remaining <= 0) return null; try { - const desktop = await this.#readDesktop(device, operation); - this.#releaseProbe(device); + const desktop = await this.#readDesktop(device, operation, remaining); if (desktop?.desktopId === desktopId) return device; + this.#releaseProbe(device, operation); } catch { - this.#releaseProbe(device); + // An unreachable or unrelated device is skipped; the picker remains the fallback. } } return null; @@ -355,16 +428,31 @@ export class WebBluetoothTransport implements BleTransport { }; } - #releaseProbe(device: WebBluetoothDevice): void { - this.#probes.delete(device); - if (this.#session?.device === device) return; - try { if (device.gatt?.connected) device.gatt.disconnect(); } catch { /* Already disconnected. */ } + #releaseProbe(device: WebBluetoothDevice, operation: number): void { + if (this.#probes.get(device) === operation) this.#probes.delete(device); + this.#closeIfOwner(device, operation); + } + + #closeIfOwner(device: WebBluetoothDevice, operation: number): void { + if (this.#session?.device === device || this.#owners.get(device.id) !== operation) return; + // Ownership is kept while a connect is pending, so its late completion is still closed. + if (!device.gatt?.connected) return; + this.#owners.delete(device.id); + try { device.gatt.disconnect(); } catch { /* Already disconnected. */ } } /** Web Bluetooth rejects overlapping GATT operations, so they run one at a time. */ #gatt(start: () => Promise, write = false): Promise { return new Promise((resolve, reject) => { - this.#queue.push({ start, resolve: resolve as (value: unknown) => void, reject, write }); + let finish!: () => void; + const done = new Promise((settle) => { finish = settle; }); + this.#queue.push({ + start, + resolve: (value) => { finish(); resolve(value as T); }, + reject: (error) => { finish(); reject(error); }, + write, + done, + }); this.#drain(); }); } @@ -373,15 +461,27 @@ export class WebBluetoothTransport implements BleTransport { if (this.#running) return; const next = this.#queue.shift(); if (!next) return; + const generation = this.#queueGeneration; this.#running = next; let operation: Promise; try { operation = next.start(); } catch (error) { operation = Promise.reject(error); } void operation.then(next.resolve, (error: unknown) => next.reject(error instanceof Error ? error : new Error('Bluetooth operation failed.'))).finally(() => { + // An operation from before a disconnect may settle late; it no longer owns the queue. + if (generation !== this.#queueGeneration) return; if (this.#running === next) this.#running = null; this.#drain(); }); } + #resetQueue(): void { + this.#queueGeneration += 1; + const error = new Error('Bluetooth operation was cancelled.'); + const queued = this.#queue; + this.#queue = []; + this.#running = null; + queued.forEach((item) => item.reject(error)); + } + #bounded(operation: Promise, message: string, timeoutMs = this.timeoutMs): Promise { return new Promise((resolve, reject) => { let settled = false; @@ -404,6 +504,8 @@ export class WebBluetoothTransport implements BleTransport { for (const cancel of [...this.#timers]) cancel(error); } + #cancellationTimeout(): number { return Math.min(1_000, this.timeoutMs); } + #requireSession(): Session { if (!this.#session) throw new Error('No PC is connected.'); return this.#session; From d73a22ae72c28a28b862eb59807ea17fa4c40ecc Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 7 Oct 2026 22:13:01 +0100 Subject: [PATCH 04/16] fix(web): own device connections per connect attempt and persist keys before use - Track connection ownership with a token per connect attempt, so a remembered- device check that times out cannot close the connection later opened to the same PC - Treat a picker the browser refuses to open (SecurityError after the tap window) like a closed picker, returning to idle so the next tap can try again - Use a newly created web encryption key only after its IndexedDB transaction commits, so values are never encrypted with a key that was not saved Co-Authored-By: Claude Opus 5.5 --- src/storage/secretStorage.web.test.ts | 26 +++++++++-- src/storage/secretStorage.web.ts | 15 +++++- src/transport/WebBluetoothTransport.test.ts | 28 +++++++++++ src/transport/WebBluetoothTransport.ts | 52 ++++++++++++--------- 4 files changed, 94 insertions(+), 27 deletions(-) diff --git a/src/storage/secretStorage.web.test.ts b/src/storage/secretStorage.web.test.ts index 3178671..a17f255 100644 --- a/src/storage/secretStorage.web.test.ts +++ b/src/storage/secretStorage.web.test.ts @@ -10,6 +10,7 @@ type Request = { result: T; error: Error | null; onsuccess: (() => void) | nu class FakeIndexedDb { stores = new Map>(); failOpen = false; + abortCommit = false; addGate: Promise | null = null; open(): Request { const request: Request = { result: null, error: null, onsuccess: null, onerror: null, onupgradeneeded: null }; @@ -17,7 +18,16 @@ class FakeIndexedDb { if (this.failOpen) { request.error = new Error('blocked'); request.onerror?.(); return; } const database = { createObjectStore: (name: string) => { this.stores.set(name, new Map()); }, - transaction: (name: string) => ({ objectStore: () => this.#store(name) }), + transaction: (name: string) => { + const transaction: { oncomplete: (() => void) | null; onabort: (() => void) | null; onerror: (() => void) | null; error: Error | null; objectStore: () => unknown } = { + oncomplete: null, onabort: null, onerror: null, error: null, + objectStore: () => this.#store(name, (failed) => setTimeout(() => { + if (failed || this.abortCommit) { transaction.error = new Error('aborted'); transaction.onabort?.(); } + else transaction.oncomplete?.(); + }, 0)), + }; + return transaction; + }, close: () => undefined, }; request.result = database; @@ -26,13 +36,13 @@ class FakeIndexedDb { }, 0); return request; } - #store(name: string) { + #store(name: string, settled: (failed: boolean) => void) { const store = this.stores.get(name)!; const respond = (action: () => T, gate: Promise | null = null): Request => { const request: Request = { result: undefined, error: null, onsuccess: null, onerror: null }; void Promise.resolve(gate).then(() => setTimeout(() => { - try { request.result = action(); request.onsuccess?.(); } - catch (error) { request.error = error as Error; request.onerror?.(); } + try { request.result = action(); request.onsuccess?.(); settled(false); } + catch (error) { request.error = error as Error; request.onerror?.(); settled(true); } }, 0)); return request; }; @@ -40,7 +50,7 @@ class FakeIndexedDb { get: (key: string) => respond(() => store.get(key)), add: (value: unknown, key: string) => respond(() => { if (store.has(key)) throw Object.assign(new Error('exists'), { name: 'ConstraintError' }); - store.set(key, value); + if (!this.abortCommit) store.set(key, value); }, this.addGate), }; } @@ -104,6 +114,12 @@ describe('web secret storage', () => { expect(await reader.getItemAsync('b')).toBe('two'); }); + it('does not use a key whose save was aborted', async () => { + indexedDb.abortCommit = true; + await expect(load().secretStorage.setItemAsync('token', 'secret-token')).rejects.toThrow(); + expect(window.localStorage.getItem('switchify.remote.secret.v1.token')).toBeNull(); + }); + it('refuses to run outside a secure context', async () => { Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false }); await expect(load().secretStorage.getItemAsync('token')).rejects.toThrow('unavailable'); diff --git a/src/storage/secretStorage.web.ts b/src/storage/secretStorage.web.ts index 541fc3b..827bc42 100644 --- a/src/storage/secretStorage.web.ts +++ b/src/storage/secretStorage.web.ts @@ -24,6 +24,14 @@ function request(operation: IDBRequest): Promise { }); } +function committed(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(transaction.error ?? unavailable()); + transaction.onerror = () => reject(transaction.error ?? unavailable()); + }); +} + async function openDatabase(): Promise { const open = window.indexedDB.open(DATABASE, 1); open.onupgradeneeded = () => open.result.createObjectStore(STORE); @@ -43,7 +51,12 @@ async function loadKey(): Promise { if (existing) return existing; const created = await window.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); try { - await request(database.transaction(STORE, 'readwrite').objectStore(STORE).add(created, KEY_ID)); + // The key is only used once it is durably stored, or values could outlive it. + const transaction = database.transaction(STORE, 'readwrite'); + const saved = committed(transaction); + void saved.catch(() => undefined); + await request(transaction.objectStore(STORE).add(created, KEY_ID)); + await saved; return created; } catch (error) { // Another tab stored its key first; every tab must use the same one. diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index c5c4cbf..d9d8120 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -314,4 +314,32 @@ describe('WebBluetoothTransport', () => { jest.useRealTimers(); } }); + + it('keeps the connection when a timed-out check of the same PC finishes after it is chosen', async () => { + jest.useFakeTimers(); + try { + const bluetooth = new FakeBluetooth(); + const slow = new FakePeripheral('web-1', status('desk-1')); + let release!: () => void; + slow.connectGate = new Promise((resolve) => { release = resolve; }); + bluetooth.remembered = [slow]; + bluetooth.chooser = async () => slow; + const transport = new WebBluetoothTransport(() => bluetooth); + const connecting = transport.resolveAndConnect('desk-1'); + await jest.advanceTimersByTimeAsync(REMEMBERED_DEVICE_BUDGET_MS + 10); + release(); + await jest.advanceTimersByTimeAsync(10); + await expect(connecting).resolves.toMatchObject({ desktopId: 'desk-1' }); + expect(slow.connected).toBe(true); + expect(transport.maxWriteValueBytes()).toBe(WEB_MAX_WRITE_VALUE_BYTES); + } finally { + jest.useRealTimers(); + } + }); + + it('treats a picker the browser refused to open as a cancelled selection', async () => { + const bluetooth = new FakeBluetooth(); + bluetooth.chooser = async () => { throw Object.assign(new Error('Must be handling a user gesture.'), { name: 'SecurityError' }); }; + await expect(new WebBluetoothTransport(() => bluetooth).resolveAndConnect('desk-1')).rejects.toBeInstanceOf(BluetoothDeviceSelectionCancelledError); + }); }); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index 7f62beb..f4eaa23 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -75,8 +75,13 @@ function toBase64(view: DataView): string { return fromByteArray(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); } +/** + * Closing the picker is a NotFoundError. A SecurityError means the browser refused to + * open it because the tap was too long ago; both leave the person to tap again. + */ function isCancellation(error: unknown): boolean { - return error instanceof Error && error.name === 'NotFoundError'; + const name = typeof error === 'object' && error !== null ? (error as { name?: unknown }).name : undefined; + return name === 'NotFoundError' || name === 'SecurityError'; } function cancelled(): Error { @@ -88,8 +93,9 @@ export class WebBluetoothTransport implements BleTransport { #session: Session | null = null; #devices = new Map(); #desktopDevices = new Map(); - /** The operation allowed to close each device's connection, so a stale attempt never closes a newer one. */ + /** The connect attempt allowed to close each device's connection, so a stale attempt never closes a newer one. */ #owners = new Map(); + #attempts = 0; #probes = new Map(); #queue: Pending[] = []; #running: Pending | null = null; @@ -139,7 +145,8 @@ export class WebBluetoothTransport implements BleTransport { if (!active) return; active = false; if (operation === this.#operation) this.#operation += 1; - if (probe) this.#releaseProbe(probe, operation); + const attempt = probe ? this.#probes.get(probe) : undefined; + if (probe && attempt !== undefined) this.#releaseProbe(probe, attempt); }; } @@ -193,7 +200,7 @@ export class WebBluetoothTransport implements BleTransport { await this.cancelPendingWrites(); this.#resetQueue(); this.#writePoisoned = false; - for (const [probe, owner] of [...this.#probes]) this.#releaseProbe(probe, owner); + for (const [probe, attempt] of [...this.#probes]) this.#releaseProbe(probe, attempt); const session = this.#session; this.#session = null; if (session) { @@ -306,9 +313,10 @@ export class WebBluetoothTransport implements BleTransport { async #open(device: WebBluetoothDevice, operation: number): Promise { this.#devices.set(device.id, device); this.#probes.delete(device); - this.#owners.set(device.id, operation); + const attempt = ++this.#attempts; + this.#owners.set(device.id, attempt); try { - const server = await this.#connectGatt(device, operation, 'connect'); + const server = await this.#connectGatt(device, operation, attempt, 'connect'); const characteristics = await this.#stage('services', () => this.#bounded(this.#characteristics(server), 'Bluetooth operation timed out.'), operation); if (operation !== this.#operation) throw cancelled(); const view = await this.#stage('status_read', () => this.#bounded(this.#gatt(() => characteristics.status.readValue()), 'Bluetooth operation timed out.'), operation); @@ -322,19 +330,19 @@ export class WebBluetoothTransport implements BleTransport { this.#writePoisoned = false; return status; } catch (error) { - this.#closeIfOwner(device, operation); + this.#closeIfOwner(device, attempt); throw error; } } /** Connects, and closes the connection if it only completes after the attempt was abandoned. */ - async #connectGatt(device: WebBluetoothDevice, operation: number, stage: ConnectionStage, timeoutMs = this.timeoutMs): Promise { + async #connectGatt(device: WebBluetoothDevice, operation: number, attempt: number, stage: ConnectionStage, timeoutMs = this.timeoutMs): Promise { const gatt = device.gatt; if (!gatt) throw new Error('This device does not support Bluetooth connections.'); let abandoned = false; const connecting = gatt.connect(); void connecting.then(() => { - if (abandoned || operation !== this.#operation) this.#closeIfOwner(device, operation); + if (abandoned || operation !== this.#operation) this.#closeIfOwner(device, attempt); }, () => undefined); try { const server = await this.#stage(stage, () => this.#bounded(connecting, 'Bluetooth operation timed out.', timeoutMs), operation); @@ -357,14 +365,15 @@ export class WebBluetoothTransport implements BleTransport { return { receive, transmit, status, response }; } - /** Reads a device's status over a short-lived probe connection owned by `operation`. */ + /** Reads a device's status over a short-lived probe connection with its own connect attempt. */ async #readDesktop(device: WebBluetoothDevice, operation: number, timeoutMs = this.timeoutMs): Promise { - this.#probes.set(device, operation); - this.#owners.set(device.id, operation); + const attempt = ++this.#attempts; + this.#probes.set(device, attempt); + this.#owners.set(device.id, attempt); const deadline = this.now() + timeoutMs; const remaining = () => Math.max(1, deadline - this.now()); try { - const server = await this.#connectGatt(device, operation, 'probe_connect', remaining()); + const server = await this.#connectGatt(device, operation, attempt, 'probe_connect', remaining()); const service = await this.#stage('probe_services', () => this.#bounded(server.getPrimaryService(BLE_UUIDS.service), 'Bluetooth operation timed out.', remaining()), operation); if (operation !== this.#operation) throw cancelled(); const characteristic = await this.#bounded(service.getCharacteristic(BLE_UUIDS.status), 'Bluetooth operation timed out.', remaining()); @@ -375,14 +384,14 @@ export class WebBluetoothTransport implements BleTransport { const status = decodeStatus(view); this.#recordStage('status_parse', status ? 'succeeded' : 'failed', operation); if (!status) { - this.#releaseProbe(device, operation); + this.#releaseProbe(device, attempt); return null; } this.#devices.set(device.id, device); this.#desktopDevices.set(status.desktopId, device.id); return this.#describe(device, status); } catch (error) { - this.#releaseProbe(device, operation); + this.#releaseProbe(device, attempt); throw error; } } @@ -411,7 +420,8 @@ export class WebBluetoothTransport implements BleTransport { try { const desktop = await this.#readDesktop(device, operation, remaining); if (desktop?.desktopId === desktopId) return device; - this.#releaseProbe(device, operation); + const attempt = this.#probes.get(device); + if (attempt !== undefined) this.#releaseProbe(device, attempt); } catch { // An unreachable or unrelated device is skipped; the picker remains the fallback. } @@ -428,13 +438,13 @@ export class WebBluetoothTransport implements BleTransport { }; } - #releaseProbe(device: WebBluetoothDevice, operation: number): void { - if (this.#probes.get(device) === operation) this.#probes.delete(device); - this.#closeIfOwner(device, operation); + #releaseProbe(device: WebBluetoothDevice, attempt: number): void { + if (this.#probes.get(device) === attempt) this.#probes.delete(device); + this.#closeIfOwner(device, attempt); } - #closeIfOwner(device: WebBluetoothDevice, operation: number): void { - if (this.#session?.device === device || this.#owners.get(device.id) !== operation) return; + #closeIfOwner(device: WebBluetoothDevice, attempt: number): void { + if (this.#session?.device === device || this.#owners.get(device.id) !== attempt) return; // Ownership is kept while a connect is pending, so its late completion is still closed. if (!device.gatt?.connected) return; this.#owners.delete(device.id); From 4b37c297e69ead271ac616a9624de126e64aadd9 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 7 Oct 2026 22:16:12 +0100 Subject: [PATCH 05/16] fix(web): explain a Bluetooth picker the browser refuses to open - Report SecurityError from requestDevice as BluetoothPickerBlockedError instead of a silent cancellation, since browsers also raise it when Bluetooth is blocked for the site - Show and announce a failure that asks the person to try again and allow Bluetooth for the site, keeping the silent return to idle only for a closed picker - Cover discovery and connection paths in ConnectionManager tests and update docs/web.md Co-Authored-By: Claude Opus 5.5 --- docs/web.md | 2 +- src/connection/ConnectionManager.ts | 12 ++++++++--- src/connection/connection.test.ts | 14 ++++++++++++- src/transport/BleTransport.ts | 7 +++++++ src/transport/WebBluetoothTransport.test.ts | 6 +++--- src/transport/WebBluetoothTransport.ts | 22 +++++++++++---------- 6 files changed, 45 insertions(+), 18 deletions(-) diff --git a/docs/web.md b/docs/web.md index 36a5d2d..ba2ae23 100644 --- a/docs/web.md +++ b/docs/web.md @@ -20,7 +20,7 @@ Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and Chr | Area | Native | Web | |---|---|---| | Transport | `ReactNativeBleTransport` (react-native-ble-plx) | `WebBluetoothTransport`, selected by `createBleTransport.web.ts` | -| Discovery | Background scan lists every nearby PC | The browser's device picker returns one chosen PC per search. Closing the picker returns to idle (`scan_cancelled`) without showing an error. | +| Discovery | Background scan lists every nearby PC | The browser's device picker returns one chosen PC per search. Closing the picker returns to idle (`scan_cancelled`) without showing an error. If the browser refuses to open the picker, because the tap was too long ago or Bluetooth is blocked for the site, the app explains this and asks you to try again. | | Saved PCs | Rescans and matches the desktop ID | Reuses devices this site may already use (`navigator.bluetooth.getDevices()`), matching by desktop ID. Otherwise it asks once through the picker, which needs a tap. | | Write size | Negotiated MTU (Android requests 517) | Fixed at 182 bytes (a 185-byte ATT MTU). The browser hides the negotiated MTU, and Switchify PC on macOS rejects long (offset) writes. | | GATT concurrency | Native transaction queue | One GATT operation at a time, because browsers reject overlapping operations | diff --git a/src/connection/ConnectionManager.ts b/src/connection/ConnectionManager.ts index 646b6ef..f240c9b 100644 --- a/src/connection/ConnectionManager.ts +++ b/src/connection/ConnectionManager.ts @@ -4,7 +4,7 @@ import { authenticatedCommand, commandPayloads, pairingRequest } from '@/domain/ import type { JsonObject, PointerProfile, ProtocolResponse } from '@/domain/protocol/types'; import { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; import type { PairingStorage, SavedPc } from '@/storage/PairingStore'; -import { BluetoothDeviceSelectionCancelledError, type BleAvailability, type BleTransport, type DiscoveredDesktop, type Unsubscribe } from '@/transport/BleTransport'; +import { BLUETOOTH_PICKER_BLOCKED_MESSAGE, BluetoothDeviceSelectionCancelledError, BluetoothPickerBlockedError, type BleAvailability, type BleTransport, type DiscoveredDesktop, type Unsubscribe } from '@/transport/BleTransport'; import { ProtocolClient, ProtocolWriteError } from './ProtocolClient'; import { pairingVerificationCode } from './verificationCode'; @@ -97,7 +97,7 @@ export class ConnectionManager { } catch (error) { if (!this.#current(operation)) return; if (error instanceof BluetoothDeviceSelectionCancelledError) await this.#selectionCancelled(operation); - else await this.#fail('Could not connect to this PC.', operation); + else await this.#fail(error instanceof BluetoothPickerBlockedError ? BLUETOOTH_PICKER_BLOCKED_MESSAGE : 'Could not connect to this PC.', operation); } } @@ -145,7 +145,7 @@ export class ConnectionManager { } catch (error) { if (!this.#current(operation)) return; if (error instanceof BluetoothDeviceSelectionCancelledError) await this.#selectionCancelled(operation); - else await this.#fail('Could not find this PC nearby.', operation); + else await this.#fail(error instanceof BluetoothPickerBlockedError ? BLUETOOTH_PICKER_BLOCKED_MESSAGE : 'Could not find this PC nearby.', operation); } } @@ -557,6 +557,12 @@ export class ConnectionManager { this.#set({ kind: 'idle', saved }); return; } + if (error instanceof BluetoothPickerBlockedError) { + this.#scanStop?.(); this.#scanStop = null; + this.diagnostics.add('scan_failed', 'error'); + this.#set({ kind: 'failed', message: BLUETOOTH_PICKER_BLOCKED_MESSAGE, saved }); + return; + } const availability = await this.transport.availability().catch(() => 'poweredOff' as BleAvailability); if (!this.#current(operation)) return; this.diagnostics.add('scan_failed', 'error'); diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts index cee4fe4..51adf82 100644 --- a/src/connection/connection.test.ts +++ b/src/connection/connection.test.ts @@ -1,6 +1,6 @@ import { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; import type { PairingStorage, SavedPc } from '@/storage/PairingStore'; -import { BluetoothDeviceSelectionCancelledError, type BleAvailability, type BleTransport, type DiscoveredDesktop, type Unsubscribe } from '@/transport/BleTransport'; +import { BLUETOOTH_PICKER_BLOCKED_MESSAGE, BluetoothDeviceSelectionCancelledError, BluetoothPickerBlockedError, type BleAvailability, type BleTransport, type DiscoveredDesktop, type Unsubscribe } from '@/transport/BleTransport'; import { ConnectionManager } from './ConnectionManager'; import { pairingVerificationCode } from './verificationCode'; @@ -97,6 +97,18 @@ describe('connection lifecycle', () => { expect(manager.snapshot().kind).toBe('idle'); }); + it('explains a picker the browser refused to open instead of returning silently', async () => { + const transport = new FakeTransport(); + const manager = new ConnectionManager(transport, new FakeStorage(), new DiagnosticLog(), async () => true); + await manager.scan(); + transport.scanError?.(new BluetoothPickerBlockedError()); + await waitFor(() => manager.snapshot().kind === 'failed'); + expect(manager.snapshot()).toMatchObject({ kind: 'failed', message: BLUETOOTH_PICKER_BLOCKED_MESSAGE }); + transport.resolveError = new BluetoothPickerBlockedError(); + await manager.connect({ ...pc('pc-1'), rssi: null }); + expect(manager.snapshot()).toMatchObject({ kind: 'failed', message: BLUETOOTH_PICKER_BLOCKED_MESSAGE }); + }); + it('discovers PCs through an injectable fake without hardware', async () => { const transport = new FakeTransport(); const manager = new ConnectionManager(transport, new FakeStorage(), new DiagnosticLog(), async () => true); diff --git a/src/transport/BleTransport.ts b/src/transport/BleTransport.ts index 79fe1ad..e5fc6cf 100644 --- a/src/transport/BleTransport.ts +++ b/src/transport/BleTransport.ts @@ -23,3 +23,10 @@ export interface BleTransport { export class BluetoothDeviceSelectionCancelledError extends Error { constructor() { super('Bluetooth device selection was cancelled.'); } } + +/** The browser refused to open its device picker: the tap was too long ago, or Bluetooth is blocked for the site. */ +export class BluetoothPickerBlockedError extends Error { + constructor() { super('The browser did not open the Bluetooth picker.'); } +} + +export const BLUETOOTH_PICKER_BLOCKED_MESSAGE = 'Your browser did not open the Bluetooth picker. Try again, and allow Bluetooth for this site if it is blocked.'; diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index d9d8120..acf5794 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -1,7 +1,7 @@ import { fromByteArray, toByteArray } from 'base64-js'; import { BLE_UUIDS } from '@/domain/protocol/constants'; -import { BluetoothDeviceSelectionCancelledError } from './BleTransport'; +import { BluetoothDeviceSelectionCancelledError, BluetoothPickerBlockedError } from './BleTransport'; import { REMEMBERED_DEVICE_BUDGET_MS, WEB_MAX_WRITE_VALUE_BYTES, @@ -337,9 +337,9 @@ describe('WebBluetoothTransport', () => { } }); - it('treats a picker the browser refused to open as a cancelled selection', async () => { + it('reports a picker the browser refused to open as blocked, not cancelled', async () => { const bluetooth = new FakeBluetooth(); bluetooth.chooser = async () => { throw Object.assign(new Error('Must be handling a user gesture.'), { name: 'SecurityError' }); }; - await expect(new WebBluetoothTransport(() => bluetooth).resolveAndConnect('desk-1')).rejects.toBeInstanceOf(BluetoothDeviceSelectionCancelledError); + await expect(new WebBluetoothTransport(() => bluetooth).resolveAndConnect('desk-1')).rejects.toBeInstanceOf(BluetoothPickerBlockedError); }); }); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index f4eaa23..131ffa2 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -4,7 +4,7 @@ import { BLE_UUIDS } from '@/domain/protocol/constants'; import { parseStatus } from '@/domain/protocol/responses'; import type { PcStatus } from '@/domain/protocol/types'; import type { ConnectionStage, ConnectionStageOutcome, DiagnosticLog } from '@/diagnostics/DiagnosticLog'; -import { BluetoothDeviceSelectionCancelledError, type BleAvailability, type BleTransport, type DiscoveredDesktop, type Unsubscribe } from './BleTransport'; +import { BluetoothDeviceSelectionCancelledError, BluetoothPickerBlockedError, type BleAvailability, type BleTransport, type DiscoveredDesktop, type Unsubscribe } from './BleTransport'; import { desktopDisplayName } from './desktopDisplayName'; import { ReadResponsePoller } from './ReadResponsePoller'; @@ -75,13 +75,15 @@ function toBase64(view: DataView): string { return fromByteArray(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); } -/** - * Closing the picker is a NotFoundError. A SecurityError means the browser refused to - * open it because the tap was too long ago; both leave the person to tap again. - */ -function isCancellation(error: unknown): boolean { - const name = typeof error === 'object' && error !== null ? (error as { name?: unknown }).name : undefined; - return name === 'NotFoundError' || name === 'SecurityError'; +function errorName(error: unknown): unknown { + return typeof error === 'object' && error !== null ? (error as { name?: unknown }).name : undefined; +} + +/** Closing the picker is a NotFoundError; a SecurityError means the browser would not open it. */ +function pickerError(error: unknown): Error | null { + if (errorName(error) === 'NotFoundError') return new BluetoothDeviceSelectionCancelledError(); + if (errorName(error) === 'SecurityError') return new BluetoothPickerBlockedError(); + return null; } function cancelled(): Error { @@ -139,7 +141,7 @@ export class WebBluetoothTransport implements BleTransport { onDesktop(desktop); })().catch((error: unknown) => { if (!active || operation !== this.#operation) return; - onError(isCancellation(error) ? new BluetoothDeviceSelectionCancelledError() : new Error('Bluetooth discovery failed.')); + onError(pickerError(error) ?? new Error('Bluetooth discovery failed.')); }); return () => { if (!active) return; @@ -187,7 +189,7 @@ export class WebBluetoothTransport implements BleTransport { } catch (error) { this.#recordStage('resolution', 'failed', operation); if (operation === this.#operation) await this.disconnect(); - throw isCancellation(error) ? new BluetoothDeviceSelectionCancelledError() : error; + throw pickerError(error) ?? error; } } From 35058e53cec12611b5a6afd57c1a94e190f1f547 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 7 Oct 2026 22:16:23 +0100 Subject: [PATCH 06/16] docs(web): describe bounded write cancellation Co-Authored-By: Claude Opus 5.5 --- docs/web.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/web.md b/docs/web.md index ba2ae23..669c5bf 100644 --- a/docs/web.md +++ b/docs/web.md @@ -24,7 +24,7 @@ Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and Chr | Saved PCs | Rescans and matches the desktop ID | Reuses devices this site may already use (`navigator.bluetooth.getDevices()`), matching by desktop ID. Otherwise it asks once through the picker, which needs a tap. | | Write size | Negotiated MTU (Android requests 517) | Fixed at 182 bytes (a 185-byte ATT MTU). The browser hides the negotiated MTU, and Switchify PC on macOS rejects long (offset) writes. | | GATT concurrency | Native transaction queue | One GATT operation at a time, because browsers reject overlapping operations | -| Write cancellation | Cancels the native transaction | An in-flight write cannot be aborted. Cancelling one makes writes unavailable until the next connection, matching the native behaviour when cancellation fails. | +| Write cancellation | Cancels the native transaction | An in-flight write cannot be aborted, so cancelling waits up to one second for it to finish. That way disconnect cleanup (drag end, modifier release) still reaches the PC. Only a write that never finishes makes writes unavailable until the next connection, matching the native behaviour when cancellation fails. | | Pairing secrets | Keychain / Keystore via `expo-secure-store` | AES-GCM ciphertext in `localStorage`, encrypted with a non-extractable key held in IndexedDB (`secretStorage.web.ts`). Clearing site data removes the pairing. | | Alerts | Native dialogs | `window.alert` and `window.confirm` (every app alert is a notice or a cancel plus one action) | | Announcements | `AccessibilityInfo` | A polite ARIA live region (`installPlatform.web.ts`) | From e9f3a0168b7ccbe7695ad6e6ef1c4e12b2a2dc98 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 7 Oct 2026 22:20:23 +0100 Subject: [PATCH 07/16] fix(web): keep inactive tabs out of the keyboard order - On the web, React Navigation leaves inactive tab scenes in the page behind the focused one and only hides them from screen readers, so Tab reached their buttons - Wrap tab scenes with a web-only screenLayout that removes unfocused scenes from layout while keeping them mounted; native tabs are unchanged - Test the focus and blur handling and document it in docs/web.md Co-Authored-By: Claude Opus 5.5 --- docs/web.md | 1 + src/app/(tabs)/_layout.tsx | 3 +- src/navigation/tabSceneLayout.tsx | 10 ++++++ src/navigation/tabSceneLayout.web.test.tsx | 36 ++++++++++++++++++++++ src/navigation/tabSceneLayout.web.tsx | 25 +++++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/navigation/tabSceneLayout.tsx create mode 100644 src/navigation/tabSceneLayout.web.test.tsx create mode 100644 src/navigation/tabSceneLayout.web.tsx diff --git a/docs/web.md b/docs/web.md index 669c5bf..7dbb34c 100644 --- a/docs/web.md +++ b/docs/web.md @@ -29,6 +29,7 @@ Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and Chr | Alerts | Native dialogs | `window.alert` and `window.confirm` (every app alert is a notice or a cancel plus one action) | | Announcements | `AccessibilityInfo` | A polite ARIA live region (`installPlatform.web.ts`) | | Diagnostics export | Share sheet | Text file download | +| Inactive tabs | Detached by the navigator | Removed from layout (`tabSceneLayout.web.tsx`), since the web navigator only hides them from screen readers and leaves their controls in the keyboard order | | Android bridge and Switch Forwarding | Android only | Unavailable | The protocol, framing limits, authentication and pairing approval are unchanged. See [protocol compatibility](protocol-compatibility.md). diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index 7ae8d44..720623e 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -5,6 +5,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { computeBottomTabBarHeight } from '@/navigation/bottomTabBarLayout'; import { tabDefinitions } from '@/navigation/tabDefinitions'; +import { tabSceneLayout } from '@/navigation/tabSceneLayout'; import { useTheme } from '@/theme/ThemeContext'; const icons = { index: 'computer', remote: 'settings-remote', settings: 'settings' } as const; @@ -16,7 +17,7 @@ export default function TabsLayout() { const tabBarHeight = computeBottomTabBarHeight(fontScale, bottom); return ( - ({ + ({ headerShown: false, tabBarActiveTintColor: scheme === 'dark' ? colors.brandText : colors.brand, tabBarInactiveTintColor: colors.textMuted, diff --git a/src/navigation/tabSceneLayout.tsx b/src/navigation/tabSceneLayout.tsx new file mode 100644 index 0000000..bd12c45 --- /dev/null +++ b/src/navigation/tabSceneLayout.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; + +type TabSceneLayout = (props: { children: ReactElement; navigation: TabSceneNavigation }) => ReactElement; + +export type TabSceneNavigation = { + isFocused(): boolean; + addListener(event: 'focus' | 'blur', listener: () => void): () => void; +}; + +export const tabSceneLayout: TabSceneLayout | undefined = undefined; diff --git a/src/navigation/tabSceneLayout.web.test.tsx b/src/navigation/tabSceneLayout.web.test.tsx new file mode 100644 index 0000000..18abc40 --- /dev/null +++ b/src/navigation/tabSceneLayout.web.test.tsx @@ -0,0 +1,36 @@ +import { act, render, screen } from '@testing-library/react-native'; +import { Text } from 'react-native'; + +import { WebTabScene, type TabSceneNavigation } from './tabSceneLayout.web'; + +class FakeNavigation implements TabSceneNavigation { + focused = false; + listeners = new Map void>>(); + isFocused = () => this.focused; + addListener(event: 'focus' | 'blur', listener: () => void): () => void { + const set = this.listeners.get(event) ?? new Set(); + set.add(listener); + this.listeners.set(event, set); + return () => set.delete(listener); + } + emit(event: 'focus' | 'blur'): void { + this.focused = event === 'focus'; + this.listeners.get(event)?.forEach((listener) => listener()); + } +} + +describe('web tab scenes', () => { + it('removes an unfocused tab from layout so its controls leave the keyboard order', async () => { + const navigation = new FakeNavigation(); + const { unmount } = await render(PCs); + const scene = () => screen.getByTestId('web-tab-scene', { includeHiddenElements: true }); + expect(scene().props.style).toMatchObject({ display: 'none' }); + await act(async () => navigation.emit('focus')); + expect(scene().props.style).toMatchObject({ display: 'flex' }); + await act(async () => navigation.emit('blur')); + expect(scene().props.style).toMatchObject({ display: 'none' }); + await unmount(); + expect(navigation.listeners.get('focus')?.size).toBe(0); + expect(navigation.listeners.get('blur')?.size).toBe(0); + }); +}); diff --git a/src/navigation/tabSceneLayout.web.tsx b/src/navigation/tabSceneLayout.web.tsx new file mode 100644 index 0000000..a198492 --- /dev/null +++ b/src/navigation/tabSceneLayout.web.tsx @@ -0,0 +1,25 @@ +import { type ReactElement, useEffect, useState } from 'react'; +import { View } from 'react-native'; + +import type { TabSceneNavigation } from './tabSceneLayout'; + +export type { TabSceneNavigation } from './tabSceneLayout'; + +/** + * On the web, inactive tabs stay in the page behind the focused one and are only + * hidden from screen readers, so their buttons still take keyboard focus. Removing + * them from layout keeps Tab order on the visible screen. + */ +export function WebTabScene({ children, navigation }: { children: ReactElement; navigation: TabSceneNavigation }) { + const [focused, setFocused] = useState(() => navigation.isFocused()); + useEffect(() => { + setFocused(navigation.isFocused()); + const removeFocus = navigation.addListener('focus', () => setFocused(true)); + const removeBlur = navigation.addListener('blur', () => setFocused(false)); + return () => { removeFocus(); removeBlur(); }; + }, [navigation]); + return {children}; +} + +export const tabSceneLayout = ({ children, navigation }: { children: ReactElement; navigation: TabSceneNavigation }): ReactElement => + {children}; From c0cdf5a2bc21ea0028667e6a421348aef6762608 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 7 Oct 2026 22:22:32 +0100 Subject: [PATCH 08/16] fix(web): read tab focus with useSyncExternalStore - Replace the effect that set focus state with a focus/blur subscription, fixing the react-hooks/set-state-in-effect lint error from the previous commit Co-Authored-By: Claude Opus 5.5 --- src/navigation/tabSceneLayout.web.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/navigation/tabSceneLayout.web.tsx b/src/navigation/tabSceneLayout.web.tsx index a198492..58f7667 100644 --- a/src/navigation/tabSceneLayout.web.tsx +++ b/src/navigation/tabSceneLayout.web.tsx @@ -1,4 +1,4 @@ -import { type ReactElement, useEffect, useState } from 'react'; +import { type ReactElement, useCallback, useSyncExternalStore } from 'react'; import { View } from 'react-native'; import type { TabSceneNavigation } from './tabSceneLayout'; @@ -11,13 +11,13 @@ export type { TabSceneNavigation } from './tabSceneLayout'; * them from layout keeps Tab order on the visible screen. */ export function WebTabScene({ children, navigation }: { children: ReactElement; navigation: TabSceneNavigation }) { - const [focused, setFocused] = useState(() => navigation.isFocused()); - useEffect(() => { - setFocused(navigation.isFocused()); - const removeFocus = navigation.addListener('focus', () => setFocused(true)); - const removeBlur = navigation.addListener('blur', () => setFocused(false)); + const subscribe = useCallback((onChange: () => void) => { + const removeFocus = navigation.addListener('focus', onChange); + const removeBlur = navigation.addListener('blur', onChange); return () => { removeFocus(); removeBlur(); }; }, [navigation]); + const isFocused = useCallback(() => navigation.isFocused(), [navigation]); + const focused = useSyncExternalStore(subscribe, isFocused, isFocused); return {children}; } From ee1376f8018c25327021aa2b6987c6329e34b769 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 7 Oct 2026 22:26:25 +0100 Subject: [PATCH 09/16] fix(web): map picker errors only from requestDevice - NotFoundError and SecurityError are now interpreted only when the browser's device picker raises them; the same names from later GATT steps, such as a missing service after the PC restarts, are reported as connection failures instead of a closed picker - Test that a missing service after choosing a PC is not treated as a cancellation Co-Authored-By: Claude Opus 5.5 --- src/transport/WebBluetoothTransport.test.ts | 15 +++++++++ src/transport/WebBluetoothTransport.ts | 36 +++++++++++++-------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index acf5794..bac7306 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -342,4 +342,19 @@ describe('WebBluetoothTransport', () => { bluetooth.chooser = async () => { throw Object.assign(new Error('Must be handling a user gesture.'), { name: 'SecurityError' }); }; await expect(new WebBluetoothTransport(() => bluetooth).resolveAndConnect('desk-1')).rejects.toBeInstanceOf(BluetoothPickerBlockedError); }); + + it('treats a missing service after the picker as a connection failure, not a closed picker', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + peripheral.getPrimaryService = async () => { throw Object.assign(new Error('No Services matching UUID found in Device.'), { name: 'NotFoundError' }); }; + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const failure = await transport.resolveAndConnect('desk-1').catch((error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + expect(failure).not.toBeInstanceOf(BluetoothDeviceSelectionCancelledError); + const onError = jest.fn(); + transport.scan(jest.fn(), onError); + await flush(); + expect(onError.mock.calls[0]?.[0]).not.toBeInstanceOf(BluetoothDeviceSelectionCancelledError); + }); }); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index 131ffa2..5e2a1eb 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -79,11 +79,8 @@ function errorName(error: unknown): unknown { return typeof error === 'object' && error !== null ? (error as { name?: unknown }).name : undefined; } -/** Closing the picker is a NotFoundError; a SecurityError means the browser would not open it. */ -function pickerError(error: unknown): Error | null { - if (errorName(error) === 'NotFoundError') return new BluetoothDeviceSelectionCancelledError(); - if (errorName(error) === 'SecurityError') return new BluetoothPickerBlockedError(); - return null; +function isPickerOutcome(error: unknown): error is Error { + return error instanceof BluetoothDeviceSelectionCancelledError || error instanceof BluetoothPickerBlockedError; } function cancelled(): Error { @@ -130,9 +127,7 @@ export class WebBluetoothTransport implements BleTransport { let active = true; let probe: WebBluetoothDevice | null = null; void (async () => { - const bluetooth = this.bluetooth(); - if (!bluetooth) throw new Error('Web Bluetooth is unavailable.'); - const device = await bluetooth.requestDevice({ filters: [{ services: [BLE_UUIDS.service] }] }); + const device = await this.#choose(); if (!active || operation !== this.#operation) return; probe = device; const desktop = await this.#readDesktop(device, operation); @@ -141,7 +136,7 @@ export class WebBluetoothTransport implements BleTransport { onDesktop(desktop); })().catch((error: unknown) => { if (!active || operation !== this.#operation) return; - onError(pickerError(error) ?? new Error('Bluetooth discovery failed.')); + onError(isPickerOutcome(error) ? error : new Error('Bluetooth discovery failed.')); }); return () => { if (!active) return; @@ -175,9 +170,7 @@ export class WebBluetoothTransport implements BleTransport { device ??= await this.#findRememberedDesktop(desktopId, operation); if (operation !== this.#operation) throw cancelled(); if (!device) { - const bluetooth = this.bluetooth(); - if (!bluetooth) throw new Error('Web Bluetooth is unavailable.'); - device = await bluetooth.requestDevice({ filters: [{ services: [BLE_UUIDS.service] }] }); + device = await this.#choose(); this.#devices.set(device.id, device); } if (operation !== this.#operation) throw cancelled(); @@ -189,7 +182,7 @@ export class WebBluetoothTransport implements BleTransport { } catch (error) { this.#recordStage('resolution', 'failed', operation); if (operation === this.#operation) await this.disconnect(); - throw pickerError(error) ?? error; + throw error; } } @@ -337,6 +330,23 @@ export class WebBluetoothTransport implements BleTransport { } } + /** + * Opens the browser's device picker. Only its errors mean the person closed the picker + * (NotFoundError) or the browser would not open it (SecurityError); the same names from + * later GATT steps are ordinary connection failures. + */ + async #choose(): Promise { + const bluetooth = this.bluetooth(); + if (!bluetooth) throw new Error('Web Bluetooth is unavailable.'); + try { + return await bluetooth.requestDevice({ filters: [{ services: [BLE_UUIDS.service] }] }); + } catch (error) { + if (errorName(error) === 'NotFoundError') throw new BluetoothDeviceSelectionCancelledError(); + if (errorName(error) === 'SecurityError') throw new BluetoothPickerBlockedError(); + throw error; + } + } + /** Connects, and closes the connection if it only completes after the attempt was abandoned. */ async #connectGatt(device: WebBluetoothDevice, operation: number, attempt: number, stage: ConnectionStage, timeoutMs = this.timeoutMs): Promise { const gatt = device.gatt; From 8dff52c5dcc12d1e9500ea7fc6a57bef3a1e77ef Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 8 Oct 2026 15:34:05 +0100 Subject: [PATCH 10/16] fix(web): submit live Enter and isolate stalled probes --- docs/accessibility.md | 5 ++ docs/physical-smoke-test.md | 9 ++++ docs/web.md | 33 +++++++++++++ src/remote/TypingSurface.tsx | 5 +- src/remote/typingInputSubmission.ts | 11 +++++ src/remote/typingInputSubmission.web.test.js | 52 ++++++++++++++++++++ src/transport/WebBluetoothTransport.test.ts | 50 +++++++++++++++++++ src/transport/WebBluetoothTransport.ts | 4 +- 8 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 src/remote/typingInputSubmission.ts create mode 100644 src/remote/typingInputSubmission.web.test.js diff --git a/docs/accessibility.md b/docs/accessibility.md index e4d86b5..42325b0 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -1,5 +1,10 @@ # Accessibility acceptance criteria +On the web, live typing must submit from both keyboard Enter and the visible Enter +control, then restore input focus after success or failure. Failure retains text +and offers Retry Enter. Draft Enter remains multiline. Verify these using actual +browser keyboard events, not only React Native's synthetic submitEditing callback. + Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and Android Switch Access. ## Custom section layouts diff --git a/docs/physical-smoke-test.md b/docs/physical-smoke-test.md index 9c28b0e..bd4dbe0 100644 --- a/docs/physical-smoke-test.md +++ b/docs/physical-smoke-test.md @@ -1,5 +1,14 @@ # Physical development-preview smoke test +## Web checks + +In Chrome on Android against Windows and macOS, verify live keyboard Enter and the +visible Enter control each send once, clear on success, and restore input focus. +After failed delivery, verify text remains and Retry Enter does not duplicate the +text. Draft Enter must insert a newline without sending. Reload the page and +reconnect a saved PC. Check fallback selection after an unreachable remembered PC. +Keep fake-browser checks separate from physical Bluetooth evidence. + Record the app commit, Switchify PC release, phone model/OS, and desktop platform for each run. Never paste pairing credentials or typed personal content into the record. ## Layout edit mode checks diff --git a/docs/web.md b/docs/web.md index 7dbb34c..e042946 100644 --- a/docs/web.md +++ b/docs/web.md @@ -34,6 +34,18 @@ Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and Chr The protocol, framing limits, authentication and pairing approval are unchanged. See [protocol compatibility](protocol-compatibility.md). +## Typing and probe recovery + +Keyboard Enter in live typing sends one Enter, clears only after acknowledgement, +and returns focus to the input. The visible Enter and Retry Enter controls use the +same delivery path. Draft Enter continues to insert a newline without sending. +RN Web 0.21 requires `blurOnSubmit` for this multiline live input; native platforms +continue to use `submitBehavior` without a blur override. + +Remembered-device probes have disposable connections and do not occupy the active +session's GATT queue. A stalled probe must time out and disconnect before fallback +selection, and a late probe completion must not affect the chosen PC. + ## Hardware validation still needed Automated tests use fake Web Bluetooth devices. Before release, check on real hardware: @@ -41,3 +53,24 @@ Automated tests use fake Web Bluetooth devices. Before release, check on real ha - pairing and control from Chrome on Android against Switchify PC on Windows and on macOS - that 182-byte writes are accepted on each PC platform, and the reconnect behaviour after a PC restarts - saved-PC reconnection after a browser restart, both with and without `getDevices()` support + +## Browser review, 2026-10-08 + +The issue #180 fixes were checked with computer control against a production Expo +web export served on loopback. An isolated test server substituted a fake +`navigator.bluetooth` before app startup; no PC input or real Bluetooth access was +used. The fixture was outside the shipped app, counted commands without retaining +their text, and was not included in the export. + +- Onboarding, discovery, pairing, profile negotiation, and a mouse click completed. +- Keyboard Enter cleared live text and returned focus after one Enter command. +- The visible Enter control also cleared text; fresh typing sent no backspaces. +- A failed Enter retained text and focus; Retry Enter succeeded without resending + chunks or sending backspaces. +- Draft Enter inserted a newline without increasing the command counts. +- Reloading exercised a remembered device whose status read never settled. After + its three-second budget, fallback selected the fake PC and restored controls. + Keyboard Enter continued to work on the new connection. + +This is browser UI and fake-transport evidence, not physical Windows/macOS BLE or +screen-reader certification. The hardware checks above remain required. diff --git a/src/remote/TypingSurface.tsx b/src/remote/TypingSurface.tsx index 2ca5323..fe6b97e 100644 --- a/src/remote/TypingSurface.tsx +++ b/src/remote/TypingSurface.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { TextInput, View } from "react-native"; +import { Platform, TextInput, View } from "react-native"; import { ControlButton } from "@/components/ControlButton"; import { StatusBadge } from "@/components/StatusBadge"; @@ -7,6 +7,7 @@ import { preferencesStore, type TypingMode } from "@/storage/PreferencesStore"; import { useTheme } from "@/theme/ThemeContext"; import { scheduleLiveTextInputFocus } from "./focusLiveTextInput"; import { LiveTypingController } from "./LiveTypingController"; +import { typingInputSubmission } from "./typingInputSubmission"; import { SurfaceLayout } from "@/layouts/SurfaceLayout"; import type { PcPlatform } from "@/domain/protocol/types"; import { useRemoteActions } from "./actions/useRemoteActions"; @@ -161,7 +162,7 @@ export function TypingSurface({ } maxLength={2000} multiline - submitBehavior={mode === "live" ? "submit" : "newline"} + {...typingInputSubmission(mode, Platform.OS)} placeholder={ mode === "live" ? "Type on your PC" diff --git a/src/remote/typingInputSubmission.ts b/src/remote/typingInputSubmission.ts new file mode 100644 index 0000000..b0d59b4 --- /dev/null +++ b/src/remote/typingInputSubmission.ts @@ -0,0 +1,11 @@ +import type { TextInputProps } from 'react-native'; +import type { TypingMode } from '@/storage/PreferencesStore'; + +export function typingInputSubmission(mode: TypingMode, platform: string): Pick { + return { + submitBehavior: mode === 'live' ? 'submit' : 'newline', + // RN Web 0.21 uses blurOnSubmit to dispatch Enter from multiline inputs. + // The existing post-submit scheduler restores focus after delivery finishes. + ...(platform === 'web' ? { blurOnSubmit: mode === 'live' } : {}), + }; +} diff --git a/src/remote/typingInputSubmission.web.test.js b/src/remote/typingInputSubmission.web.test.js new file mode 100644 index 0000000..a008e8b --- /dev/null +++ b/src/remote/typingInputSubmission.web.test.js @@ -0,0 +1,52 @@ +/** @jest-environment jsdom */ +/* global describe, beforeEach, afterEach, it, jest, expect */ +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import TextInput from 'react-native-web/dist/cjs/exports/TextInput'; + +import { typingInputSubmission } from './typingInputSubmission'; + +describe('browser typing submission with the installed React Native Web input', () => { + let host; + let root; + beforeEach(() => { + global.IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + }); + + it.each(['live', 'draft'])('handles keyboard Enter in %s mode', async (mode) => { + const submit = jest.fn(); + await act(async () => root.render(createElement(TextInput, { + multiline: true, + ...typingInputSubmission(mode, 'web'), + onSubmitEditing: submit, + }))); + const input = host.querySelector('textarea'); + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + await act(async () => input.dispatchEvent(event)); + expect(submit).toHaveBeenCalledTimes(mode === 'live' ? 1 : 0); + expect(event.defaultPrevented).toBe(mode === 'live'); + }); + + it('does not submit while an IME composition is active', async () => { + const submit = jest.fn(); + await act(async () => root.render(createElement(TextInput, { + multiline: true, ...typingInputSubmission('live', 'web'), onSubmitEditing: submit, + }))); + await act(async () => host.querySelector('textarea').dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', isComposing: true, bubbles: true, cancelable: true, + }))); + expect(submit).not.toHaveBeenCalled(); + }); + + it.each(['android', 'ios'])('preserves native submission props on %s', (platform) => { + expect(typingInputSubmission('live', platform)).toEqual({ submitBehavior: 'submit' }); + expect(typingInputSubmission('draft', platform)).toEqual({ submitBehavior: 'newline' }); + }); +}); diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index bac7306..92cc77d 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -100,6 +100,56 @@ class FakeBluetooth implements WebBluetooth { const flush = async () => { for (let index = 0; index < 100; index += 1) await Promise.resolve(); }; describe('WebBluetoothTransport', () => { + it.each(['resolve', 'reject'] as const)('does not let a timed-out probe block the chosen PC or its queue after late %s', async (outcome) => { + jest.useFakeTimers(); + try { + const bluetooth = new FakeBluetooth(); + const stale = new FakePeripheral('stale', status('other')); + let resolveRead!: (value: DataView) => void; + let rejectRead!: (error: Error) => void; + stale.characteristics[BLE_UUIDS.status]!.readValue = () => new Promise((resolve, reject) => { + resolveRead = resolve; rejectRead = reject; + }); + const chosen = new FakePeripheral('chosen', status('wanted')); + bluetooth.remembered = [stale]; + bluetooth.chooser = async () => chosen; + const transport = new WebBluetoothTransport(() => bluetooth); + const connecting = transport.resolveAndConnect('wanted'); + await jest.advanceTimersByTimeAsync(REMEMBERED_DEVICE_BUDGET_MS + 10); + await expect(connecting).resolves.toMatchObject({ desktopId: 'wanted' }); + expect(stale.connected).toBe(false); + expect(chosen.connected).toBe(true); + expect(bluetooth.requests).toBe(1); + if (outcome === 'resolve') resolveRead(view(status('other'))); + else rejectRead(new Error('late native failure')); + await flush(); + await transport.writeFrame('YQ=='); + expect(await transport.verifyConnection('wanted')).toBe(true); + expect(chosen.overlapping).toBe(false); + await transport.disconnect(); + } finally { + jest.useRealTimers(); + } + }); + + it('does not open the picker or revive a connection after disconnect during a probe read', async () => { + const bluetooth = new FakeBluetooth(); + const stale = new FakePeripheral('stale', status('wanted')); + let resolveRead!: (value: DataView) => void; + stale.characteristics[BLE_UUIDS.status]!.readValue = () => new Promise((resolve) => { resolveRead = resolve; }); + bluetooth.remembered = [stale]; + const transport = new WebBluetoothTransport(() => bluetooth); + const connecting = transport.resolveAndConnect('wanted'); + const rejected = expect(connecting).rejects.toThrow('cancelled'); + await flush(); + await transport.disconnect(); + resolveRead(view(status('wanted'))); + await rejected; + expect(stale.connected).toBe(false); + expect(bluetooth.requests).toBe(0); + expect(await transport.verifyConnection('wanted')).toBe(false); + }); + it('reports when the browser has no Web Bluetooth', async () => { expect(await new WebBluetoothTransport(() => null).availability()).toBe('unsupported'); const bluetooth = new FakeBluetooth(); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index 5e2a1eb..ab5dfc5 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -390,7 +390,9 @@ export class WebBluetoothTransport implements BleTransport { if (operation !== this.#operation) throw cancelled(); const characteristic = await this.#bounded(service.getCharacteristic(BLE_UUIDS.status), 'Bluetooth operation timed out.', remaining()); if (operation !== this.#operation) throw cancelled(); - const view = await this.#stage('status_read', () => this.#bounded(this.#gatt(() => characteristic.readValue()), 'Bluetooth operation timed out.', remaining()), operation); + // Probe operations are sequential on a disposable connection, not the session + // queue. A timed-out read must not block a subsequently chosen PC. + const view = await this.#stage('status_read', () => this.#bounded(characteristic.readValue(), 'Bluetooth operation timed out.', remaining()), operation); if (operation !== this.#operation) throw cancelled(); this.#recordStage('status_parse', 'started', operation); const status = decodeStatus(view); From de897ad99c843110630bdcc142830901c32b0315 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 8 Oct 2026 17:21:47 +0100 Subject: [PATCH 11/16] fix(web): serialize connection characteristic discovery --- docs/web.md | 10 ++++ src/diagnostics/DiagnosticLog.ts | 5 ++ src/transport/WebBluetoothTransport.test.ts | 58 +++++++++++++++++++-- src/transport/WebBluetoothTransport.ts | 40 ++++++++------ 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/docs/web.md b/docs/web.md index e042946..6650b93 100644 --- a/docs/web.md +++ b/docs/web.md @@ -48,6 +48,16 @@ selection, and a late probe completion must not affect the chosen PC. ## Hardware validation still needed +Connection discovery now reads status before looking up command and notification +characteristics, one operation at a time. Only PCs advertising `read-v1` are asked +for the read-response characteristic. Discovery shares one ten-second budget and +checks cancellation between operations. Diagnostics identify each lookup using +fixed labels, never browser error text or Bluetooth identifiers. + +Issue #182 follows a real Chrome failure after status discovery and before pairing. +The automated sequential-discovery tests are regression evidence, not proof that +this resolves that hardware failure. A successful real pairing remains required. + Automated tests use fake Web Bluetooth devices. Before release, check on real hardware: - pairing and control from Chrome on Android against Switchify PC on Windows and on macOS diff --git a/src/diagnostics/DiagnosticLog.ts b/src/diagnostics/DiagnosticLog.ts index 549f99c..324e56b 100644 --- a/src/diagnostics/DiagnosticLog.ts +++ b/src/diagnostics/DiagnosticLog.ts @@ -6,6 +6,11 @@ const connectionStages = { priority: 'Request Android connection priority (optional)', mtu: 'Negotiate Bluetooth MTU', services: 'Discover connection services', + primary_service: 'Discover PC primary service', + status_characteristic: 'Discover PC status characteristic', + receive_characteristic: 'Discover PC command characteristic', + transmit_characteristic: 'Discover PC notification characteristic', + response_characteristic: 'Discover PC read-response characteristic', probe_connect: 'Connect for discovery status', probe_services: 'Discover status services', status_read: 'Read discovery status', diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index 92cc77d..138ede8 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -1,6 +1,7 @@ import { fromByteArray, toByteArray } from 'base64-js'; import { BLE_UUIDS } from '@/domain/protocol/constants'; +import { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; import { BluetoothDeviceSelectionCancelledError, BluetoothPickerBlockedError } from './BleTransport'; import { REMEMBERED_DEVICE_BUDGET_MS, @@ -45,6 +46,7 @@ class FakePeripheral extends EventTarget implements WebBluetoothDevice, WebBluet busy = false; overlapping = false; connects = 0; + discovered: string[] = []; writeGate: Promise | null = null; connectGate: Promise | null = null; characteristics: Record; @@ -64,9 +66,13 @@ class FakePeripheral extends EventTarget implements WebBluetoothDevice, WebBluet if (uuid !== BLE_UUIDS.service) throw new Error('missing service'); return { getCharacteristic: async (characteristic: string) => { - const found = this.characteristics[characteristic]; - if (!found) throw new Error('missing characteristic'); - return found; + this.discovered.push(characteristic); + if (this.busy) throw new Error('GATT operation already in progress'); + return this.operation(() => { + const found = this.characteristics[characteristic]; + if (!found) throw new Error('missing characteristic'); + return found; + }); }, }; } @@ -100,6 +106,52 @@ class FakeBluetooth implements WebBluetooth { const flush = async () => { for (let index = 0; index < 100; index += 1) await Promise.resolve(); }; describe('WebBluetoothTransport', () => { + it('discovers sequentially and never requests read-response on a notification PC', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + await transport.resolveAndConnect('desk-1'); + expect(peripheral.discovered).toEqual([BLE_UUIDS.status, BLE_UUIDS.receive, BLE_UUIDS.transmit]); + expect(peripheral.overlapping).toBe(false); + await transport.disconnect(); + }); + + it('fails safely when a read-v1 PC omits its required response characteristic', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1', { responseTransport: 'read-v1' })); + bluetooth.chooser = async () => peripheral; + const diagnostics = new DiagnosticLog(); + const transport = new WebBluetoothTransport(() => bluetooth, 100, diagnostics); + await expect(transport.resolveAndConnect('desk-1')).rejects.toThrow(); + expect(diagnostics.export()).toContain('ble_response_characteristic_failed'); + expect(diagnostics.export()).not.toContain('missing characteristic'); + expect(peripheral.connected).toBe(false); + }); + + it.each(['disconnect', 'timeout'] as const)('does not continue discovery after %s and a late service result', async (reason) => { + jest.useFakeTimers(); + try { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + const service = await peripheral.getPrimaryService(BLE_UUIDS.service); + let release!: (service: WebBluetoothService) => void; + peripheral.getPrimaryService = () => new Promise((resolve) => { release = resolve; }); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth, 100); + const connecting = transport.resolveAndConnect('desk-1'); + const rejected = expect(connecting).rejects.toThrow(); + await flush(); + if (reason === 'disconnect') await transport.disconnect(); + else await jest.advanceTimersByTimeAsync(100); + await rejected; + release(service); + await flush(); + expect(peripheral.discovered).toEqual([]); + expect(peripheral.connected).toBe(false); + } finally { jest.useRealTimers(); } + }); + it.each(['resolve', 'reject'] as const)('does not let a timed-out probe block the chosen PC or its queue after late %s', async (outcome) => { jest.useFakeTimers(); try { diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index ab5dfc5..48ab28b 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -312,12 +312,7 @@ export class WebBluetoothTransport implements BleTransport { this.#owners.set(device.id, attempt); try { const server = await this.#connectGatt(device, operation, attempt, 'connect'); - const characteristics = await this.#stage('services', () => this.#bounded(this.#characteristics(server), 'Bluetooth operation timed out.'), operation); - if (operation !== this.#operation) throw cancelled(); - const view = await this.#stage('status_read', () => this.#bounded(this.#gatt(() => characteristics.status.readValue()), 'Bluetooth operation timed out.'), operation); - if (operation !== this.#operation) throw cancelled(); - const status = decodeStatus(view); - if (!status) throw new Error('Bluetooth discovery status is invalid.'); + const { characteristics, status } = await this.#stage('services', () => this.#characteristics(server, operation), operation); const readReplies = status.responseTransport === 'read-v1'; if (readReplies && !characteristics.response) throw new Error('Bluetooth discovery status is invalid.'); this.#desktopDevices.set(status.desktopId, device.id); @@ -366,15 +361,30 @@ export class WebBluetoothTransport implements BleTransport { } } - async #characteristics(server: WebBluetoothServer): Promise { - const service = await server.getPrimaryService(BLE_UUIDS.service); - const [receive, transmit, status, response] = await Promise.all([ - service.getCharacteristic(BLE_UUIDS.receive), - service.getCharacteristic(BLE_UUIDS.transmit), - service.getCharacteristic(BLE_UUIDS.status), - service.getCharacteristic(BLE_UUIDS.response).catch(() => null), - ]); - return { receive, transmit, status, response }; + async #characteristics(server: WebBluetoothServer, operation: number): Promise<{ characteristics: Characteristics; status: PcStatus }> { + const deadline = this.now() + this.timeoutMs; + const step = async (stage: ConnectionStage, action: () => Promise): Promise => { + if (operation !== this.#operation) throw cancelled(); + const remaining = deadline - this.now(); + if (remaining <= 0) throw new Error('Bluetooth operation timed out.'); + const result = await this.#stage(stage, () => this.#bounded(action(), 'Bluetooth operation timed out.', remaining), operation); + if (operation !== this.#operation) throw cancelled(); + return result; + }; + // Discovery is sequential too. Do not ask notification-based PCs for the + // read-v1 characteristic that they do not expose. + const service = await step('primary_service', () => server.getPrimaryService(BLE_UUIDS.service)); + const statusCharacteristic = await step('status_characteristic', () => service.getCharacteristic(BLE_UUIDS.status)); + const view = await step('status_read', () => statusCharacteristic.readValue()); + const status = decodeStatus(view); + this.#recordStage('status_parse', status ? 'succeeded' : 'failed', operation); + if (!status) throw new Error('Bluetooth discovery status is invalid.'); + const receive = await step('receive_characteristic', () => service.getCharacteristic(BLE_UUIDS.receive)); + const transmit = await step('transmit_characteristic', () => service.getCharacteristic(BLE_UUIDS.transmit)); + const response = status.responseTransport === 'read-v1' + ? await step('response_characteristic', () => service.getCharacteristic(BLE_UUIDS.response)) + : null; + return { characteristics: { receive, transmit, status: statusCharacteristic, response }, status }; } /** Reads a device's status over a short-lived probe connection with its own connect attempt. */ From 862ad0f7011302e432d0ecb73aa5591d623e4467 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 8 Oct 2026 17:22:55 +0100 Subject: [PATCH 12/16] fix(web): recheck ownership before publishing session --- src/transport/WebBluetoothTransport.test.ts | 14 ++++++++++++++ src/transport/WebBluetoothTransport.ts | 1 + 2 files changed, 15 insertions(+) diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index 138ede8..28bd6b7 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -106,6 +106,20 @@ class FakeBluetooth implements WebBluetooth { const flush = async () => { for (let index = 0; index < 100; index += 1) await Promise.resolve(); }; describe('WebBluetoothTransport', () => { + it('does not publish a session when disconnected as discovery finishes', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const diagnostics = { addConnectionStage: jest.fn() }; + const transport = new WebBluetoothTransport(() => bluetooth, 100, diagnostics); + diagnostics.addConnectionStage.mockImplementation((stage, outcome) => { + if (stage === 'services' && outcome === 'succeeded') void transport.disconnect(); + }); + await expect(transport.resolveAndConnect('desk-1')).rejects.toThrow('cancelled'); + expect(() => transport.maxWriteValueBytes()).toThrow('No PC is connected.'); + expect(peripheral.connected).toBe(false); + }); + it('discovers sequentially and never requests read-response on a notification PC', async () => { const bluetooth = new FakeBluetooth(); const peripheral = new FakePeripheral('web-1', status('desk-1')); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index 48ab28b..53dfc47 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -313,6 +313,7 @@ export class WebBluetoothTransport implements BleTransport { try { const server = await this.#connectGatt(device, operation, attempt, 'connect'); const { characteristics, status } = await this.#stage('services', () => this.#characteristics(server, operation), operation); + if (operation !== this.#operation) throw cancelled(); const readReplies = status.responseTransport === 'read-v1'; if (readReplies && !characteristics.response) throw new Error('Bluetooth discovery status is invalid.'); this.#desktopDevices.set(status.desktopId, device.id); From 90c14c759a901f8684a9f6989ec05488023630d5 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 8 Oct 2026 20:24:49 +0100 Subject: [PATCH 13/16] fix(web): retain selected discovery connection for pairing --- docs/web.md | 10 ++++- src/connection/ConnectionManager.ts | 7 +-- src/connection/connection.test.ts | 21 +++++++++ src/diagnostics/DiagnosticLog.ts | 1 + src/transport/BleTransport.ts | 2 + src/transport/WebBluetoothTransport.test.ts | 47 +++++++++++++++++++++ src/transport/WebBluetoothTransport.ts | 37 +++++++++++++--- 7 files changed, 113 insertions(+), 12 deletions(-) diff --git a/docs/web.md b/docs/web.md index 6650b93..393dc0f 100644 --- a/docs/web.md +++ b/docs/web.md @@ -55,8 +55,14 @@ checks cancellation between operations. Diagnostics identify each lookup using fixed labels, never browser error text or Bluetooth identifiers. Issue #182 follows a real Chrome failure after status discovery and before pairing. -The automated sequential-discovery tests are regression evidence, not proof that -this resolves that hardware failure. A successful real pairing remains required. +The current-code hardware retry narrowed the failure to primary-service discovery +after reconnecting. Sequential characteristic discovery alone did not fix it. +The web transport now explicitly retains the selected completed discovery probe +across scan stop and connection preparation. It reuses that live service and +rereads status before pairing; a changed desktop identity still fails. Normal +scan stop, explicit disconnect, cancellation and failed setup close the probe. +Native transports keep their existing preparation path. A successful real pairing +with this handoff remains required before release. Automated tests use fake Web Bluetooth devices. Before release, check on real hardware: diff --git a/src/connection/ConnectionManager.ts b/src/connection/ConnectionManager.ts index f240c9b..fe8119b 100644 --- a/src/connection/ConnectionManager.ts +++ b/src/connection/ConnectionManager.ts @@ -85,8 +85,9 @@ export class ConnectionManager { async connect(desktop: DiscoveredDesktop): Promise { const operation = ++this.#operation; + const retained = !this.#client && this.transport.retainDiscoveredConnection?.(desktop.desktopId) === true; this.#scanStop?.(); this.#scanStop = null; - await this.#teardownConnection(); + await this.#teardownConnection(retained); if (!this.#current(operation)) return; this.#set({ kind: 'connecting', desktop }); this.diagnostics.add('connecting'); @@ -496,14 +497,14 @@ export class ConnectionManager { this.#set({ kind: 'failed', message, saved }); } - async #teardownConnection(): Promise { + async #teardownConnection(preserveDiscovery = false): Promise { this.#cancelProfileRecovery(); this.#cancelHealthTimer(); this.#disconnectStop?.(); this.#disconnectStop = null; const client = this.#client; this.#client = null; this.#token = null; if (client) await client.close(); - await this.transport.disconnect().catch(() => undefined); + if (!preserveDiscovery) await this.transport.disconnect().catch(() => undefined); } #scheduleHealth(delay = 5_000): void { diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts index 51adf82..7711386 100644 --- a/src/connection/connection.test.ts +++ b/src/connection/connection.test.ts @@ -66,6 +66,27 @@ const waitFor = async (condition: () => boolean): Promise => { }; describe('connection lifecycle', () => { + it('claims a discovered connection before stopping the scan and skips intermediate disconnect', async () => { + const transport = new FakeTransport(); + const manager = new ConnectionManager(transport, new FakeStorage(), new DiagnosticLog(), async () => true); + const desktop = { ...pc('desk-1'), rssi: null }; + await manager.scan(); + const disconnect = jest.spyOn(transport, 'disconnect'); + const retained = jest.fn(() => { + expect(transport.scanStops).toBe(0); + return true; + }); + Object.assign(transport, { retainDiscoveredConnection: retained }); + transport.resolveGate = new Promise(() => undefined); + void manager.connect(desktop); + await waitFor(() => transport.resolveDesktopIds.length === 1); + expect(retained).toHaveBeenCalledWith('desk-1'); + expect(transport.scanStops).toBe(1); + expect(disconnect).not.toHaveBeenCalled(); + await manager.disconnect(); + expect(disconnect).toHaveBeenCalled(); + }); + it('matches the Android pairing verification algorithm', () => { expect(pairingVerificationCode('desktop-1', 'device-1', 'nonce-1')).toBe('215918'); expect(pairingVerificationCode('0:0:1280:720:1.5', 'android-device-id', 'random-request-nonce')).toBe('735258'); diff --git a/src/diagnostics/DiagnosticLog.ts b/src/diagnostics/DiagnosticLog.ts index 324e56b..5253cd2 100644 --- a/src/diagnostics/DiagnosticLog.ts +++ b/src/diagnostics/DiagnosticLog.ts @@ -7,6 +7,7 @@ const connectionStages = { mtu: 'Negotiate Bluetooth MTU', services: 'Discover connection services', primary_service: 'Discover PC primary service', + discovery_handoff: 'Reuse selected discovery connection', status_characteristic: 'Discover PC status characteristic', receive_characteristic: 'Discover PC command characteristic', transmit_characteristic: 'Discover PC notification characteristic', diff --git a/src/transport/BleTransport.ts b/src/transport/BleTransport.ts index e5fc6cf..495b37c 100644 --- a/src/transport/BleTransport.ts +++ b/src/transport/BleTransport.ts @@ -7,6 +7,8 @@ export type BleAvailability = 'ready' | 'unauthorized' | 'poweredOff' | 'unsuppo export interface BleTransport { availability(): Promise; scan(onDesktop: (desktop: DiscoveredDesktop) => void, onError: (error: Error) => void): Unsubscribe; + /** Claim a completed discovery connection before stopping its scan. Disconnect still releases it. */ + retainDiscoveredConnection?(desktopId: string): boolean; connect(peripheralId: string): Promise; resolveAndConnect(desktopId: string): Promise; disconnect(): Promise; diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index 28bd6b7..ef2df6a 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -106,6 +106,53 @@ class FakeBluetooth implements WebBluetooth { const flush = async () => { for (let index = 0; index < 100; index += 1) await Promise.resolve(); }; describe('WebBluetoothTransport', () => { + it('hands a selected probe to pairing without reconnecting or rediscovering its service', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + const primary = jest.spyOn(peripheral, 'getPrimaryService'); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const stop = transport.scan(jest.fn(), jest.fn()); + await flush(); + expect(transport.retainDiscoveredConnection('desk-1')).toBe(true); + stop(); + expect(peripheral.connected).toBe(true); + await expect(transport.resolveAndConnect('desk-1')).resolves.toMatchObject({ desktopId: 'desk-1' }); + expect(peripheral.connects).toBe(1); + expect(primary).toHaveBeenCalledTimes(1); + await transport.disconnect(); + expect(peripheral.connected).toBe(false); + }); + + it.each(['cancel', 'wrong-id', 'lost-link'] as const)('does not retain an unsafe probe after %s', async (reason) => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const stop = transport.scan(jest.fn(), jest.fn()); + await flush(); + if (reason === 'lost-link') peripheral.disconnect(); + expect(transport.retainDiscoveredConnection(reason === 'wrong-id' ? 'other' : 'desk-1')).toBe(reason === 'cancel'); + stop(); + await transport.disconnect(); + expect(peripheral.connected).toBe(false); + expect(transport.retainDiscoveredConnection('desk-1')).toBe(false); + }); + + it('rereads identity on a retained connection and rejects a changed desktop', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const stop = transport.scan(jest.fn(), jest.fn()); + await flush(); + transport.retainDiscoveredConnection('desk-1'); + stop(); + peripheral.status = status('other'); + await expect(transport.resolveAndConnect('desk-1')).rejects.toThrow('different PC'); + expect(peripheral.connected).toBe(false); + }); + it('does not publish a session when disconnected as discovery finishes', async () => { const bluetooth = new FakeBluetooth(); const peripheral = new FakePeripheral('web-1', status('desk-1')); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index 53dfc47..96fdeef 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -96,6 +96,7 @@ export class WebBluetoothTransport implements BleTransport { #owners = new Map(); #attempts = 0; #probes = new Map(); + #discoveredProbe: { device: WebBluetoothDevice; server: WebBluetoothServer; service: WebBluetoothService; desktopId: string; attempt: number; retained: boolean } | null = null; #queue: Pending[] = []; #running: Pending | null = null; #queueGeneration = 0; @@ -143,10 +144,17 @@ export class WebBluetoothTransport implements BleTransport { active = false; if (operation === this.#operation) this.#operation += 1; const attempt = probe ? this.#probes.get(probe) : undefined; - if (probe && attempt !== undefined) this.#releaseProbe(probe, attempt); + if (probe && attempt !== undefined && !(this.#discoveredProbe?.device === probe && this.#discoveredProbe.retained)) this.#releaseProbe(probe, attempt); }; } + retainDiscoveredConnection(desktopId: string): boolean { + const probe = this.#discoveredProbe; + if (!probe || probe.desktopId !== desktopId || !probe.server.connected || this.#session || this.#owners.get(probe.device.id) !== probe.attempt) return false; + probe.retained = true; + return true; + } + async connect(peripheralId: string): Promise { await this.disconnect(); const operation = ++this.#operation; @@ -161,7 +169,7 @@ export class WebBluetoothTransport implements BleTransport { } async resolveAndConnect(desktopId: string): Promise { - await this.disconnect(); + await this.#disconnectExceptDiscovery(desktopId); const operation = ++this.#operation; this.#recordStage('resolution', 'started', operation); try { @@ -187,6 +195,10 @@ export class WebBluetoothTransport implements BleTransport { } async disconnect(): Promise { + await this.#disconnectExceptDiscovery(); + } + + async #disconnectExceptDiscovery(desktopId?: string): Promise { this.#operation += 1; this.#responsePoller?.stop(); this.#responsePoller = null; @@ -195,7 +207,11 @@ export class WebBluetoothTransport implements BleTransport { await this.cancelPendingWrites(); this.#resetQueue(); this.#writePoisoned = false; - for (const [probe, attempt] of [...this.#probes]) this.#releaseProbe(probe, attempt); + for (const [probe, attempt] of [...this.#probes]) { + const ready = this.#discoveredProbe; + if (desktopId !== undefined && ready?.retained && ready.desktopId === desktopId && ready.device === probe && ready.server.connected) continue; + this.#releaseProbe(probe, attempt); + } const session = this.#session; this.#session = null; if (session) { @@ -307,12 +323,16 @@ export class WebBluetoothTransport implements BleTransport { async #open(device: WebBluetoothDevice, operation: number): Promise { this.#devices.set(device.id, device); + const probe = this.#discoveredProbe; + const reusable = probe?.device === device && probe.server.connected && this.#owners.get(device.id) === probe.attempt ? probe : null; + if (probe?.device === device) this.#discoveredProbe = null; this.#probes.delete(device); const attempt = ++this.#attempts; this.#owners.set(device.id, attempt); try { - const server = await this.#connectGatt(device, operation, attempt, 'connect'); - const { characteristics, status } = await this.#stage('services', () => this.#characteristics(server, operation), operation); + const server = reusable?.server ?? await this.#connectGatt(device, operation, attempt, 'connect'); + if (reusable) this.#recordStage('discovery_handoff', 'succeeded', operation); + const { characteristics, status } = await this.#stage('services', () => this.#characteristics(server, operation, reusable?.service), operation); if (operation !== this.#operation) throw cancelled(); const readReplies = status.responseTransport === 'read-v1'; if (readReplies && !characteristics.response) throw new Error('Bluetooth discovery status is invalid.'); @@ -362,7 +382,7 @@ export class WebBluetoothTransport implements BleTransport { } } - async #characteristics(server: WebBluetoothServer, operation: number): Promise<{ characteristics: Characteristics; status: PcStatus }> { + async #characteristics(server: WebBluetoothServer, operation: number, discoveredService?: WebBluetoothService): Promise<{ characteristics: Characteristics; status: PcStatus }> { const deadline = this.now() + this.timeoutMs; const step = async (stage: ConnectionStage, action: () => Promise): Promise => { if (operation !== this.#operation) throw cancelled(); @@ -374,7 +394,7 @@ export class WebBluetoothTransport implements BleTransport { }; // Discovery is sequential too. Do not ask notification-based PCs for the // read-v1 characteristic that they do not expose. - const service = await step('primary_service', () => server.getPrimaryService(BLE_UUIDS.service)); + const service = discoveredService ?? await step('primary_service', () => server.getPrimaryService(BLE_UUIDS.service)); const statusCharacteristic = await step('status_characteristic', () => service.getCharacteristic(BLE_UUIDS.status)); const view = await step('status_read', () => statusCharacteristic.readValue()); const status = decodeStatus(view); @@ -412,6 +432,8 @@ export class WebBluetoothTransport implements BleTransport { this.#releaseProbe(device, attempt); return null; } + if (operation !== this.#operation) throw cancelled(); + this.#discoveredProbe = { device, server, service, desktopId: status.desktopId, attempt, retained: false }; this.#devices.set(device.id, device); this.#desktopDevices.set(status.desktopId, device.id); return this.#describe(device, status); @@ -464,6 +486,7 @@ export class WebBluetoothTransport implements BleTransport { } #releaseProbe(device: WebBluetoothDevice, attempt: number): void { + if (this.#discoveredProbe?.device === device && this.#discoveredProbe.attempt === attempt) this.#discoveredProbe = null; if (this.#probes.get(device) === attempt) this.#probes.delete(device); this.#closeIfOwner(device, attempt); } From 37ab5032a70517d2727233eecd0bb2e4eaa0f8eb Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 8 Oct 2026 20:46:08 +0100 Subject: [PATCH 14/16] fix(web): address cancellation accessibility and identity review --- docs/accessibility.md | 5 ++ docs/web.md | 9 +++ src/components/ActionButton.tsx | 5 +- src/components/ControlButton.tsx | 7 +- src/components/IconButton.tsx | 8 ++- src/components/SelectorField.tsx | 3 +- src/components/webControlAccessibility.ts | 19 ++++++ .../webControlAccessibility.web.test.js | 35 ++++++++++ src/remote/RemoteDeviceSwitcher.tsx | 2 + src/storage/PairingStore.test.ts | 11 ++++ src/storage/PairingStore.ts | 3 + src/storage/secretStorage.ts | 4 +- src/storage/secretStorage.web.test.ts | 64 +++++++++++++++++++ src/storage/secretStorage.web.ts | 30 ++++++++- src/transport/WebBluetoothTransport.test.ts | 28 ++++++++ src/transport/WebBluetoothTransport.ts | 10 +-- 16 files changed, 228 insertions(+), 15 deletions(-) create mode 100644 src/components/webControlAccessibility.ts create mode 100644 src/components/webControlAccessibility.web.test.js diff --git a/docs/accessibility.md b/docs/accessibility.md index 42325b0..94548fd 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -1,5 +1,10 @@ # Accessibility acceptance criteria +Web selector buttons must include their full current value in the accessible name. +Toggle controls expose pressed/unpressed state; ordinary action buttons must not be +announced as toggles. Busy and disabled controls expose those states to browser +assistive technology. Check the rendered DOM in addition to native prop tests. + On the web, live typing must submit from both keyboard Enter and the visible Enter control, then restore input focus after success or failure. Failure retains text and offers Retry Enter. Draft Enter remains multiline. Verify these using actual diff --git a/docs/web.md b/docs/web.md index 393dc0f..ecb92a6 100644 --- a/docs/web.md +++ b/docs/web.md @@ -34,6 +34,15 @@ Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and Chr The protocol, framing limits, authentication and pairing approval are unchanged. See [protocol compatibility](protocol-compatibility.md). +First-time browser device identity creation uses an exclusive Web Lock, so tabs +share one persisted identity. Waiting for a lock is bounded to five seconds. If +locks are unavailable or persistence fails, initialization fails safely rather +than using an unsaved identity. Existing encrypted identities remain readable. +This keeps the existing keys, ciphertext format and native storage path unchanged. + +Connection cancellation includes initial cleanup: a stopped or replaced attempt +must not reopen the picker or begin another connection after cleanup completes. + ## Typing and probe recovery Keyboard Enter in live typing sends one Enter, clears only after acknowledgement, diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx index 03dadbe..87cba71 100644 --- a/src/components/ActionButton.tsx +++ b/src/components/ActionButton.tsx @@ -1,6 +1,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import type { ComponentProps, Ref } from 'react'; -import { ActivityIndicator, Animated, Pressable, Text, View, type View as NativeView } from 'react-native'; +import { ActivityIndicator, Animated, Platform, Pressable, Text, View, type View as NativeView } from 'react-native'; +import { webControlAccessibility } from './webControlAccessibility'; import { useTheme } from '@/theme/ThemeContext'; import { usePressScale } from './usePressScale'; @@ -14,7 +15,7 @@ export function ActionButton({ label, onPress, disabled = false, busy = false, s const background = resolvedTone === 'primary' ? colors.brand : resolvedTone === 'danger' ? colors.dangerTint : resolvedTone === 'secondary' ? colors.surfaceRaised : 'transparent'; const foreground = resolvedTone === 'primary' ? colors.onBrand : resolvedTone === 'danger' ? colors.danger : resolvedTone === 'tertiary' ? colors.brandText : colors.text; return ( - ({ alignItems: 'center', backgroundColor: pressed && !disabled ? (resolvedTone === 'primary' ? colors.brandPressed : colors.surfacePressed) : background, borderColor: resolvedTone === 'tertiary' ? 'transparent' : resolvedTone === 'danger' ? colors.danger : colors.border, borderRadius: radii.md, borderWidth: resolvedTone === 'primary' ? 0 : 1, justifyContent: 'center', minHeight: 52, opacity: disabled ? 0.4 : 1, paddingHorizontal: spacing.lg })}> + ({ alignItems: 'center', backgroundColor: pressed && !disabled ? (resolvedTone === 'primary' ? colors.brandPressed : colors.surfacePressed) : background, borderColor: resolvedTone === 'tertiary' ? 'transparent' : resolvedTone === 'danger' ? colors.danger : colors.border, borderRadius: radii.md, borderWidth: resolvedTone === 'primary' ? 0 : 1, justifyContent: 'center', minHeight: 52, opacity: disabled ? 0.4 : 1, paddingHorizontal: spacing.lg })}> {busy ? : icon ? : null} {label} diff --git a/src/components/ControlButton.tsx b/src/components/ControlButton.tsx index 431b54a..bc7a225 100644 --- a/src/components/ControlButton.tsx +++ b/src/components/ControlButton.tsx @@ -2,6 +2,7 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import type { ComponentProps, Ref } from "react"; import { Animated, + Platform, Pressable, Text, type AccessibilityRole, @@ -9,6 +10,7 @@ import { } from "react-native"; import { useTheme } from "@/theme/ThemeContext"; import { usePressScale } from "./usePressScale"; +import { webControlAccessibility } from './webControlAccessibility'; type IconName = ComponentProps["name"]; @@ -17,7 +19,7 @@ export function ControlButton({ accessibilityLabel = label, hint, onPress, - selected = false, + selected, disabled = false, danger = false, role = "button", @@ -60,7 +62,8 @@ export function ControlButton({ accessibilityRole={role} accessibilityLabel={accessibilityLabel} accessibilityHint={hint} - accessibilityState={{ selected, disabled }} + accessibilityState={{ selected: selected ?? false, disabled }} + {...webControlAccessibility(Platform.OS, { selected, disabled, role })} disabled={disabled} onPress={onPress} onPressIn={press.pressIn} diff --git a/src/components/IconButton.tsx b/src/components/IconButton.tsx index 79bc081..146bdc2 100644 --- a/src/components/IconButton.tsx +++ b/src/components/IconButton.tsx @@ -1,14 +1,15 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import type { ComponentProps } from "react"; -import { Animated, Pressable } from "react-native"; +import { Animated, Platform, Pressable } from "react-native"; import { useTheme } from "@/theme/ThemeContext"; import { usePressScale } from "./usePressScale"; +import { webControlAccessibility } from './webControlAccessibility'; export function IconButton({ icon, accessibilityLabel, hint, - selected = false, + selected, disabled = false, onPress, }: { @@ -34,7 +35,8 @@ export function IconButton({ accessibilityRole="button" accessibilityLabel={accessibilityLabel} accessibilityHint={hint} - accessibilityState={{ selected, disabled }} + accessibilityState={{ selected: selected ?? false, disabled }} + {...webControlAccessibility(Platform.OS, { selected, disabled })} disabled={disabled} onPress={onPress} onPressIn={press.pressIn} diff --git a/src/components/SelectorField.tsx b/src/components/SelectorField.tsx index 7044ef5..56e73df 100644 --- a/src/components/SelectorField.tsx +++ b/src/components/SelectorField.tsx @@ -7,6 +7,7 @@ import { AppText } from './AppText'; import { ControlButton } from './ControlButton'; import { focusAccessibilityTarget } from './accessibilityFocus'; import { useTheme } from '@/theme/ThemeContext'; +import { webControlAccessibility } from './webControlAccessibility'; export type SelectorOption = { key: T; label: string; disabled?: boolean }; @@ -91,7 +92,7 @@ export function SelectorField({ label, options, selec }; return <> - { setSelectionError(null); setVisible(true); }} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed ? colors.surfacePressed : colors.surfaceRaised, borderColor: colors.border, borderRadius: radii.lg, borderWidth: 1, flexDirection: 'row', gap: spacing.sm, minHeight: 48, padding: spacing.md })}> + { setSelectionError(null); setVisible(true); }} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed ? colors.surfacePressed : colors.surfaceRaised, borderColor: colors.border, borderRadius: radii.lg, borderWidth: 1, flexDirection: 'row', gap: spacing.sm, minHeight: 48, padding: spacing.md })}> {label}: {selected?.label ?? ''} diff --git a/src/components/webControlAccessibility.ts b/src/components/webControlAccessibility.ts new file mode 100644 index 0000000..6bb75eb --- /dev/null +++ b/src/components/webControlAccessibility.ts @@ -0,0 +1,19 @@ +/** Native state/value props are not forwarded by React Native Web. */ +export function webControlAccessibility(platform: string, { label, value, selected, disabled, busy, expanded, role = 'button' }: { + label?: string; + value?: string; + selected?: boolean | undefined; + disabled?: boolean; + busy?: boolean; + expanded?: boolean; + role?: string; +}) { + if (platform !== 'web') return {}; + return { + ...(label !== undefined && value !== undefined ? { 'aria-label': `${label}: ${value}` } : {}), + ...(selected !== undefined ? role === 'button' ? { 'aria-pressed': selected } : { 'aria-selected': selected } : {}), + ...(disabled !== undefined ? { 'aria-disabled': disabled } : {}), + ...(busy !== undefined ? { 'aria-busy': busy } : {}), + ...(expanded !== undefined ? { 'aria-expanded': expanded } : {}), + }; +} diff --git a/src/components/webControlAccessibility.web.test.js b/src/components/webControlAccessibility.web.test.js new file mode 100644 index 0000000..403b165 --- /dev/null +++ b/src/components/webControlAccessibility.web.test.js @@ -0,0 +1,35 @@ +/** @jest-environment jsdom */ +/* global describe, it, expect */ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server.node'; +import Pressable from 'react-native-web/dist/cjs/exports/Pressable'; +import { webControlAccessibility } from './webControlAccessibility'; + +function render(props) { + const host = document.createElement('div'); + host.innerHTML = renderToStaticMarkup(createElement(Pressable, { + accessibilityRole: 'button', accessibilityLabel: 'Surface', + disabled: props.disabled, + ...webControlAccessibility('web', props), + })); + return host.querySelector('button'); +} + +describe('browser control accessibility', () => { + it('includes the complete current value in the accessible name', () => { + const button = render({ label: 'Surface', value: 'Typing', expanded: true }); + expect(button.getAttribute('aria-label')).toBe('Surface: Typing'); + expect(button.getAttribute('aria-expanded')).toBe('true'); + expect(button.hasAttribute('aria-pressed')).toBe(false); + }); + it.each([true, false])('exposes toggle state %s and disabled/busy state', (selected) => { + const button = render({ selected, disabled: true, busy: true }); + expect(button.getAttribute('aria-pressed')).toBe(String(selected)); + expect(button.getAttribute('aria-disabled')).toBe('true'); + expect(button.getAttribute('aria-busy')).toBe('true'); + expect(button.hasAttribute('aria-selected')).toBe(false); + }); + it.each(['android', 'ios'])('does not override native props on %s', (platform) => { + expect(webControlAccessibility(platform, { label: 'Surface', value: 'Mouse', selected: true })).toEqual({}); + }); +}); diff --git a/src/remote/RemoteDeviceSwitcher.tsx b/src/remote/RemoteDeviceSwitcher.tsx index 934f050..7f0e69e 100644 --- a/src/remote/RemoteDeviceSwitcher.tsx +++ b/src/remote/RemoteDeviceSwitcher.tsx @@ -7,6 +7,7 @@ import { AppText } from '@/components/AppText'; import { ControlButton } from '@/components/ControlButton'; import { EmptyState } from '@/components/EmptyState'; import { focusAccessibilityTarget } from '@/components/accessibilityFocus'; +import { webControlAccessibility } from '@/components/webControlAccessibility'; import type { ConnectionManager, ConnectionState } from '@/connection/ConnectionManager'; import type { SavedPc } from '@/storage/PairingStore'; import { useTheme } from '@/theme/ThemeContext'; @@ -94,6 +95,7 @@ export function RemoteDeviceSwitcher({ connection, manager, managePcs }: { conne accessibilityRole="button" accessibilityLabel="Switch PC" accessibilityValue={{ text: `${presentation.status}, ${presentation.name}` }} + {...webControlAccessibility(Platform.OS, { label: 'Switch PC', value: `${presentation.status}, ${presentation.name}`, expanded: visible })} accessibilityHint="Opens your saved PCs." onPress={open} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed ? colors.surfacePressed : colors.surfaceRaised, borderColor: colors.border, borderRadius: radii.lg, borderWidth: 1, flexDirection: 'row', gap: spacing.md, minHeight: 56, paddingHorizontal: spacing.md, paddingVertical: spacing.sm })} diff --git a/src/storage/PairingStore.test.ts b/src/storage/PairingStore.test.ts index e4df379..f396881 100644 --- a/src/storage/PairingStore.test.ts +++ b/src/storage/PairingStore.test.ts @@ -34,6 +34,17 @@ function fixture() { } describe('PairingStore transactions', () => { + it('uses platform atomic identity initialization when available', async () => { + const secrets = new SecretMemory(); + const atomic = jest.fn(async (_name: string, _create: () => string) => 'shared-device'); + const storage = Object.assign(secrets, { getOrCreateItemAsync: atomic }); + const first = new PairingStore(new PublicMemory(), storage); + const second = new PairingStore(new PublicMemory(), storage); + expect(await Promise.all([first.getDeviceId(), second.getDeviceId()])).toEqual(['shared-device', 'shared-device']); + expect(atomic).toHaveBeenCalledWith('switchify.remote.device-id.v1', expect.any(Function)); + expect(secrets.values.size).toBe(0); + }); + it('rolls back a token update when saving the public index fails', async () => { const { store, publicStorage } = fixture(); publicStorage.failSet = 1; diff --git a/src/storage/PairingStore.ts b/src/storage/PairingStore.ts index 888ebda..19d8597 100644 --- a/src/storage/PairingStore.ts +++ b/src/storage/PairingStore.ts @@ -36,6 +36,9 @@ export class PairingStore implements PairingStorage { constructor(private readonly publicStorage: PublicStorage = AsyncStorage, private readonly secretStorage: SecretStorage = platformSecretStorage) {} async getDeviceId(): Promise { + if (this.secretStorage.getOrCreateItemAsync) { + return this.secretStorage.getOrCreateItemAsync(DEVICE_ID_KEY, () => `remote-${Crypto.randomUUID()}`); + } const existing = await this.secretStorage.getItemAsync(DEVICE_ID_KEY); if (existing) return existing; const created = `remote-${Crypto.randomUUID()}`; diff --git a/src/storage/secretStorage.ts b/src/storage/secretStorage.ts index 08ee111..f491fac 100644 --- a/src/storage/secretStorage.ts +++ b/src/storage/secretStorage.ts @@ -1,5 +1,7 @@ import * as SecureStore from 'expo-secure-store'; -export type SecretStorage = Pick; +export type SecretStorage = Pick & { + getOrCreateItemAsync?: (name: string, create: () => string) => Promise; +}; export const secretStorage: SecretStorage = SecureStore; diff --git a/src/storage/secretStorage.web.test.ts b/src/storage/secretStorage.web.test.ts index a17f255..7ef61bc 100644 --- a/src/storage/secretStorage.web.test.ts +++ b/src/storage/secretStorage.web.test.ts @@ -70,9 +70,73 @@ beforeEach(() => { Object.defineProperty(window, 'indexedDB', { configurable: true, value: indexedDb }); Object.defineProperty(window, 'crypto', { configurable: true, value: webcrypto }); Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true }); + let queue: Promise = Promise.resolve(); + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { + request: (_name: string, _options: unknown, callback: () => Promise) => { + const next = queue.catch(() => undefined).then(callback); + queue = next; + return next; + }, + } }); }); describe('web secret storage', () => { + it('creates one persistent identity across independent browser instances', async () => { + const first = load().secretStorage; + const second = load().secretStorage; + const createFirst = jest.fn(() => 'remote-one'); + const createSecond = jest.fn(() => 'remote-two'); + const identities = await Promise.all([ + first.getOrCreateItemAsync!('device-id', createFirst), + second.getOrCreateItemAsync!('device-id', createSecond), + ]); + expect(identities[0]).toBe(identities[1]); + expect(createFirst.mock.calls.length + createSecond.mock.calls.length).toBe(1); + expect(await load().secretStorage.getItemAsync('device-id')).toBe(identities[0]); + expect(Object.values({ ...window.localStorage }).join(' ')).not.toContain(identities[0]); + }); + + it('preserves an existing identity without requiring a lock', async () => { + const storage = load().secretStorage; + await storage.setItemAsync('device-id', 'existing'); + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: undefined }); + const create = jest.fn(() => 'replacement'); + expect(await storage.getOrCreateItemAsync!('device-id', create)).toBe('existing'); + expect(create).not.toHaveBeenCalled(); + }); + + it('fails safely without cross-tab locks rather than racing a new identity', async () => { + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: undefined }); + const storage = load().secretStorage; + const create = jest.fn(() => 'unsafe'); + await expect(storage.getOrCreateItemAsync!('device-id', create)).rejects.toThrow('unavailable'); + expect(create).not.toHaveBeenCalled(); + expect(await storage.getItemAsync('device-id')).toBeNull(); + }); + + it('bounds waiting for a lock held by another tab', async () => { + jest.useFakeTimers(); + try { + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { + request: (_name: string, options: { signal: AbortSignal }) => new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new Error('lock aborted'))); + }), + } }); + const create = jest.fn(() => 'late'); + const pending = load().secretStorage.getOrCreateItemAsync!('device-id', create); + const rejected = expect(pending).rejects.toThrow('unavailable'); + await jest.advanceTimersByTimeAsync(5_000); + await rejected; + expect(create).not.toHaveBeenCalled(); + } finally { jest.useRealTimers(); } + }); + + it('does not return an identity when persistence fails', async () => { + indexedDb.abortCommit = true; + await expect(load().secretStorage.getOrCreateItemAsync!('device-id', () => 'unsaved')).rejects.toThrow('unavailable'); + expect(window.localStorage.length).toBe(0); + }); + it('round-trips a secret without storing it in plain text', async () => { const { secretStorage } = load(); await secretStorage.setItemAsync('switchify.remote.token.pc-1', 'secret-token'); diff --git a/src/storage/secretStorage.web.ts b/src/storage/secretStorage.web.ts index 827bc42..f08a5ee 100644 --- a/src/storage/secretStorage.web.ts +++ b/src/storage/secretStorage.web.ts @@ -1,6 +1,8 @@ import type * as SecureStore from 'expo-secure-store'; -export type SecretStorage = Pick; +export type SecretStorage = Pick & { + getOrCreateItemAsync?: (name: string, create: () => string) => Promise; +}; const DATABASE = 'switchify-remote-secrets'; const STORE = 'keys'; @@ -133,4 +135,28 @@ async function deleteItemAsync(name: string): Promise { window.localStorage.removeItem(`${VALUE_PREFIX}${name}`); } -export const secretStorage = { getItemAsync, setItemAsync, deleteItemAsync } as SecretStorage; +async function getOrCreateItemAsync(name: string, create: () => string): Promise { + const existing = await getItemAsync(name); + if (existing) return existing; + // The identity read, generation and encrypted write must share a cross-tab lock. + // A per-tab promise only protects one instance and can invalidate another pairing. + if (!window.navigator.locks) throw unavailable(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5_000); + try { + return await window.navigator.locks.request(`switchify.remote.initialize.${name}`, { mode: 'exclusive', signal: controller.signal }, async () => { + clearTimeout(timer); + const raced = await getItemAsync(name); + if (raced) return raced; + const created = create(); + await setItemAsync(name, created); + return created; + }); + } catch { + throw unavailable(); + } finally { + clearTimeout(timer); + } +} + +export const secretStorage: SecretStorage = { getItemAsync, setItemAsync, deleteItemAsync, getOrCreateItemAsync }; diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index ef2df6a..3ce0310 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -106,6 +106,34 @@ class FakeBluetooth implements WebBluetooth { const flush = async () => { for (let index = 0; index < 100; index += 1) await Promise.resolve(); }; describe('WebBluetoothTransport', () => { + it.each(['connect', 'resolve'] as const)('does not restart %s when disconnected during initial cleanup', async (method) => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.remembered = [peripheral]; + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const connecting = method === 'connect' ? transport.connect('web-1') : transport.resolveAndConnect('desk-1'); + const rejected = expect(connecting).rejects.toThrow('cancelled'); + await transport.disconnect(); + await rejected; + expect(bluetooth.requests).toBe(0); + expect(peripheral.connects).toBe(0); + }); + + it('lets only the replacement attempt proceed after initial cleanup', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const first = transport.resolveAndConnect('old'); + const rejected = expect(first).rejects.toThrow('cancelled'); + await expect(transport.resolveAndConnect('desk-1')).resolves.toMatchObject({ desktopId: 'desk-1' }); + await rejected; + expect(bluetooth.requests).toBe(1); + expect(peripheral.connected).toBe(true); + await transport.disconnect(); + }); + it('hands a selected probe to pairing without reconnecting or rediscovering its service', async () => { const bluetooth = new FakeBluetooth(); const peripheral = new FakePeripheral('web-1', status('desk-1')); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index 96fdeef..1738804 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -156,8 +156,9 @@ export class WebBluetoothTransport implements BleTransport { } async connect(peripheralId: string): Promise { - await this.disconnect(); const operation = ++this.#operation; + await this.#disconnectExceptDiscovery(undefined, operation); + if (operation !== this.#operation) throw cancelled(); let device = this.#devices.get(peripheralId) ?? null; if (!device) { const remembered = await this.#rememberedDevices(); @@ -169,8 +170,9 @@ export class WebBluetoothTransport implements BleTransport { } async resolveAndConnect(desktopId: string): Promise { - await this.#disconnectExceptDiscovery(desktopId); const operation = ++this.#operation; + await this.#disconnectExceptDiscovery(desktopId, operation); + if (operation !== this.#operation) throw cancelled(); this.#recordStage('resolution', 'started', operation); try { const known = this.#desktopDevices.get(desktopId); @@ -198,13 +200,13 @@ export class WebBluetoothTransport implements BleTransport { await this.#disconnectExceptDiscovery(); } - async #disconnectExceptDiscovery(desktopId?: string): Promise { - this.#operation += 1; + async #disconnectExceptDiscovery(desktopId?: string, operation = ++this.#operation): Promise { this.#responsePoller?.stop(); this.#responsePoller = null; this.#notificationsReady = null; this.#cancelTimers(); await this.cancelPendingWrites(); + if (operation !== this.#operation) return; this.#resetQueue(); this.#writePoisoned = false; for (const [probe, attempt] of [...this.#probes]) { From a48d1d93ee2933ba912653c377b11a8450500eb5 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 8 Oct 2026 20:55:26 +0100 Subject: [PATCH 15/16] chore(deps): update Expo SDK 57 patches --- package-lock.json | 351 +++++++++++++++++++++++----------------------- package.json | 16 +-- 2 files changed, 185 insertions(+), 182 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8a7562c..a92e060 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,28 +8,28 @@ "name": "switchify-remote", "version": "1.0.0-beta.28", "dependencies": { - "@expo/ui": "~57.0.18", + "@expo/ui": "~57.0.22", "@expo/vector-icons": "^15.0.2", "@noble/hashes": "^2.3.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.3.16", "base64-js": "^1.5.1", - "expo": "~57.0.22", + "expo": "~57.0.27", "expo-blur": "~57.0.3", - "expo-build-properties": "~57.0.17", + "expo-build-properties": "~57.0.22", "expo-clipboard": "~57.0.2", - "expo-constants": "~57.0.18", + "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", "expo-dev-client": "~57.0.19", "expo-device": "~57.0.2", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", - "expo-glass-effect": "~57.0.3", + "expo-glass-effect": "~57.0.4", "expo-image": "~57.0.5", - "expo-linking": "~57.0.10", - "expo-router": "~57.0.21", + "expo-linking": "~57.0.12", + "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", - "expo-sharing": "~57.0.19", + "expo-sharing": "~57.0.22", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-symbols": "~57.0.3", @@ -89,6 +89,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1154,7 +1155,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1170,7 +1170,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1202,7 +1201,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1405,6 +1403,7 @@ "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" @@ -1416,6 +1415,7 @@ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1665,15 +1665,15 @@ } }, "node_modules/@expo/config": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.9.tgz", - "integrity": "sha512-dmzlKraIFxa7wLwV6K7WzI8jp6QZpW6Mc5mGjLimJUFjzh4uQdYaT3m3plEutM5yxBoBEwqzks7l+I/ljCbxAQ==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.10.tgz", + "integrity": "sha512-YpexTqLWEXdbbXgHh7Zhpq+qA5diuvO46lb7TiY81JwqKLAl9zK31APY1D7jUFlDX56owje5RtK0o5bMmGIMUg==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.9", + "@expo/config-plugins": "~57.0.10", "@expo/config-types": "^57.0.2", "@expo/json-file": "^11.0.1", - "@expo/require-utils": "^57.0.5", + "@expo/require-utils": "^57.0.6", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -1683,15 +1683,15 @@ } }, "node_modules/@expo/config-plugins": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.9.tgz", - "integrity": "sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.10.tgz", + "integrity": "sha512-XFcj01blofSohoOxibrPsrsFPt2gAigrcWE3s6Vg+rSHvP9QVEfnyfmJNVsuIVT0EjY/1rZVJ4SajNDR5cJjpA==", "license": "MIT", "dependencies": { "@expo/config-types": "^57.0.2", "@expo/json-file": "~11.0.1", "@expo/plist": "^0.8.1", - "@expo/require-utils": "^57.0.5", + "@expo/require-utils": "^57.0.6", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -1710,9 +1710,9 @@ "license": "MIT" }, "node_modules/@expo/devcert": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", - "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.2.tgz", + "integrity": "sha512-wz1YedXwdNbYqk/PRr34vixnuw1+l6uSwmPXUj649cot5iYNccb2zIOnK7Nonb1Pfd/dxAY6CvU6KM3uSdxC7A==", "license": "MIT", "dependencies": { "@expo/sudo-prompt": "^9.3.1", @@ -1803,12 +1803,12 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.11.5", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.5.tgz", - "integrity": "sha512-KPQBTpmpAfy/Vu9y4wPW808/qtZxjYmyJg8cm2QCPAupp+qEWA3b5zmk0ulOwQ9OgeHxuCPgUqWgkwHFo7UsrQ==", + "version": "0.11.6", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.6.tgz", + "integrity": "sha512-rmMpn7QGp6pUGRCQ3vvB9RP/vqq2q/PpToRh4dL/FbhSZOLZ1YTotpuLHf71FGOEfgKil/4PCd3hkWe/CCeiEg==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^57.0.5", + "@expo/require-utils": "^57.0.6", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", @@ -1886,19 +1886,19 @@ } }, "node_modules/@expo/metro-config": { - "version": "57.0.12", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.12.tgz", - "integrity": "sha512-S62Lrq35HZqBFD55423pmWb8PjaiR/W02zQC1uECBmw1vTN8WZaFz4TJ0i21EeJzwfebMb9MLxL8JZ102Z6VbA==", + "version": "57.0.13", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.13.tgz", + "integrity": "sha512-r+YCwqh8FM5cymkC3MdYO32DqLZQ2wDWkGEnzwgwI40Bj355BvL4XoY0hGgao9vNHjYa7be7XxbVqtMTi76snw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", - "@expo/config": "~57.0.9", + "@expo/config": "~57.0.10", "@expo/env": "~2.4.3", "@expo/json-file": "~11.0.1", "@expo/metro": "~56.0.2", - "@expo/require-utils": "^57.0.5", + "@expo/require-utils": "^57.0.6", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", @@ -1925,23 +1925,22 @@ } }, "node_modules/@expo/metro-file-map": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-57.0.3.tgz", - "integrity": "sha512-1OXy+uPYY5uc7Tm4VBsd2NRn+3wHhqeqNuEO/Xo4kmYgv8FjYgUAc+bUXON9FpC2ikcLn4EVlGM9ce2exx9Mlg==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-57.0.4.tgz", + "integrity": "sha512-gLABIbU3SlxRVVCe56ZfYFbxjhdpAKomQYr6LIeDYf7rca8Pfvzv8FS39hH0+IcdpEHly2Jy0X2nd4/obd8Kig==", "license": "MIT", "dependencies": { "debug": "^4.3.4", "fb-watchman": "^2.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", "walker": "^1.0.8" } }, "node_modules/@expo/metro-runtime": { - "version": "57.0.15", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.15.tgz", - "integrity": "sha512-9RaFkqIzRud6ol9fc5ZKHgNDYeDjQVBdAQC+rjSQ6RiKV0pY+SjDqGmKY5a4M9TaV0qLMCNNrp5R88BqC2xbuQ==", + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.16.tgz", + "integrity": "sha512-x43WgZoICpp37BhDV7vixe0bNxbVPPGaBLHYc98ZJ8MU+UC2dk39Y2XvkfYMRRbAUSIkCIJiwdERnptlPg0Ckw==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.4", @@ -2295,27 +2294,27 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "57.0.16", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.16.tgz", - "integrity": "sha512-QA8oMZ7y70kjOzw+abhRl9uYC8Nu/abeUTHr0YVxb3pgIC14gc7w543bYtI+5gEDI3q55jKUViIauC76QvZN1w==", + "version": "57.0.17", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.17.tgz", + "integrity": "sha512-PnPw4QT8afAA1RqTktK89+JpyJz46cZsZx6gEAe6QCHgOHc7dlA0rXlOFy0xbHpBnYT8x7wl6vTz2ydT99gykw==", "license": "MIT", "dependencies": { - "@expo/config": "~57.0.9", - "@expo/config-plugins": "~57.0.9", + "@expo/config": "~57.0.10", + "@expo/config-plugins": "~57.0.10", "@expo/config-types": "^57.0.2", - "@expo/image-utils": "^0.11.5", + "@expo/image-utils": "^0.11.6", "@expo/json-file": "^11.0.1", "@react-native/normalize-colors": "0.86.3", "debug": "^4.3.1", - "expo-modules-autolinking": "~57.0.13", + "expo-modules-autolinking": "~57.0.14", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "node_modules/@expo/require-utils": { - "version": "57.0.5", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.5.tgz", - "integrity": "sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.6.tgz", + "integrity": "sha512-PKWfFdMgwWrlSP6NGplCi2nK0dEM5MeJ2Cj1ijr5VWTmZ+pKsJsJw2dWkBtlFDyxMY8eUjCPZ7hoqFEnbc0Pcw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -2362,9 +2361,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "57.0.18", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.18.tgz", - "integrity": "sha512-9RkA26v0FSyHlkQCyavZrT8EzsALfkNHIFO5uC+IsAxzWzItdOWdy6asjpg1yaCvBEGMYkP17FUZxluhVbeleA==", + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.22.tgz", + "integrity": "sha512-v0p397fI7XhYBt+O5Q4MCWKB1lwR8cA2xjoQbc/V6W+EmP9qU1sHh0eQ7QHdeE/lyNjWW4wIut0/H/8OjcHo3w==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -2402,10 +2401,10 @@ } }, "node_modules/@expo/xcpretty": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz", - "integrity": "sha512-J3eL4n4h5QTwfD0SIz8OIk6/+sOL/hFZAMacgCM07UNlxBQfJipEpIC2AQxvGkbYeStByJ0TVhQAJo+DeNgaSQ==", - "license": "BSD-3-Clause", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.6.tgz", + "integrity": "sha512-uRaZZ/IsX1WkFW69xxTZtMeRvI5ziD9yoian+FMqdhITkU6NGvIWd5uRMh2Gbo/6xstpQ4v0GtF1V5YatTwzDA==", + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", @@ -3497,7 +3496,6 @@ "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.86.3.tgz", "integrity": "sha512-/eqs/Hy9RZRcjdcs4wj3Cqmxvtb3NM5g+Uuh1RIvsjynMO8PRsrVWWLWgBcZL/jYUo+ogXd2NFB0W8L5Bg89xw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", @@ -3545,7 +3543,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz", "integrity": "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==", "license": "MIT", - "peer": true, "dependencies": { "hermes-parser": "0.36.0" } @@ -3554,15 +3551,13 @@ "version": "0.36.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@react-native/babel-preset/node_modules/hermes-parser": { "version": "0.36.0", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", "license": "MIT", - "peer": true, "dependencies": { "hermes-estree": "0.36.0" } @@ -3694,6 +3689,7 @@ "integrity": "sha512-oMmBwXUpTgJvGlcXQQPuiQLykoM8jsWYp6RwdJ2NWyIWzJcGp1rmSr3XV7istoIcUt0wdOVLtQs0A0nDm9ND+A==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/js-polyfills": "0.86.3", @@ -3722,7 +3718,6 @@ "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.86.3.tgz", "integrity": "sha512-0nlwVkG0uT9o72nrGTKUz/IqDnCkM4zE0ihe4r+OJcxH93yBdDm58AXJ3EXZeSjv3osHEFXU7ceajdZiS34mTw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.86.3", @@ -3740,15 +3735,13 @@ "version": "0.36.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@react-native/metro-babel-transformer/node_modules/hermes-parser": { "version": "0.36.0", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", "license": "MIT", - "peer": true, "dependencies": { "hermes-estree": "0.36.0" } @@ -3758,7 +3751,6 @@ "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.86.3.tgz", "integrity": "sha512-qdzDMepV2xdUhsO4XC7idnnbt8K6+Hd59AjeK9y4KS86fxSQq5oL0TAbqUuajDGR7beNQoYTuS6AowN/zP0oaQ==", "license": "MIT", - "peer": true, "dependencies": { "@react-native/js-polyfills": "0.86.3", "@react-native/metro-babel-transformer": "0.86.3", @@ -4126,6 +4118,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4136,7 +4129,6 @@ "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "*" } @@ -4224,6 +4216,7 @@ "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", @@ -4809,6 +4802,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5308,9 +5302,9 @@ } }, "node_modules/babel-plugin-react-native-web": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", - "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.4.tgz", + "integrity": "sha512-FSclsDTGi5EcvpbW3TM7s8GLWwQqo5wI9zQqJr2m0IoJRenSkp1+bVvdeZXTNznI5Ms9Bry7DLBR+C5Shb9gtw==", "license": "MIT" }, "node_modules/babel-plugin-syntax-hermes-parser": { @@ -5359,9 +5353,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "57.0.11", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.11.tgz", - "integrity": "sha512-R0NouDI3nzQUsjBh5TUJSDXLmuVzE2bp2YE0ywJkKxzrdWVjShpAlZMBuDeOQy6iKGB6ZCCvMqbQvpqgNMJ7iw==", + "version": "57.0.14", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.14.tgz", + "integrity": "sha512-ALhPgXS0pvMcuORYh3THz4qYhQlOsRIw6FB+AbawM81tvP9V9pZpiK+Au9wihyYIb/Hdv2Z7Zwud4tFXF1ysJQ==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -5410,7 +5404,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^57.0.18", + "expo-widgets": "^57.0.23", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -5556,6 +5550,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -6778,6 +6773,7 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6985,6 +6981,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7540,31 +7537,32 @@ } }, "node_modules/expo": { - "version": "57.0.22", - "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.22.tgz", - "integrity": "sha512-YWUGqWtchsRQzcbU+Iqqt/1kFbZ9Pp3/eg+UenINahNt2RIaDElFJFFWLD1Yfex6WnBm6ESSE8h0/ja28hRqZg==", + "version": "57.0.27", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.27.tgz", + "integrity": "sha512-Jc3nByHsmzjakvIrqV3/3viVj+jFw1OCfMc+WpClemwL3/hSrS7ktkVCACSTI7/j+1uH+eHmBJwe1kujDLMnbQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^57.0.24", - "@expo/config": "~57.0.9", - "@expo/config-plugins": "~57.0.9", + "@expo/cli": "^57.0.28", + "@expo/config": "~57.0.10", + "@expo/config-plugins": "~57.0.10", "@expo/devtools": "~57.0.1", "@expo/dom-webview": "~57.0.1", "@expo/fingerprint": "^0.20.13", "@expo/local-build-cache-provider": "^57.0.8", "@expo/log-box": "^57.0.4", "@expo/metro": "~56.0.2", - "@expo/metro-config": "~57.0.12", + "@expo/metro-config": "~57.0.13", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~57.0.11", - "expo-asset": "~57.0.17", - "expo-constants": "~57.0.18", + "babel-preset-expo": "~57.0.14", + "expo-asset": "~57.0.19", + "expo-constants": "~57.0.21", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-keep-awake": "~57.0.2", - "expo-modules-autolinking": "~57.0.13", - "expo-modules-core": "~57.0.18", + "expo-modules-autolinking": "~57.0.14", + "expo-modules-core": "~57.0.21", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -7602,13 +7600,13 @@ } }, "node_modules/expo-asset": { - "version": "57.0.17", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.17.tgz", - "integrity": "sha512-qSdWZBSfEg06o3GJMwcj36tw0vXkB6eYjqkQistWmyJkHxhUOKnsKJnev6OraXxLdoI+KOd1+qgp/ENx8gcqYA==", + "version": "57.0.19", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.19.tgz", + "integrity": "sha512-y9ymKidyPBDUFXljbvo7LNgivQvMhEJEoEXi6aLRugUxRDUvAKoZsy+KB5UXSjLMS7l7JNDwYKk5thovI0MLog==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.11.5", - "expo-constants": "~57.0.18" + "@expo/image-utils": "^0.11.6", + "expo-constants": "~57.0.21" }, "peerDependencies": { "expo": "*", @@ -7628,9 +7626,9 @@ } }, "node_modules/expo-build-properties": { - "version": "57.0.17", - "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.17.tgz", - "integrity": "sha512-+qA5we2h1Gpnd24RGCR1amfSx7Mtf8glZ0jA5bbe1Nfdw9NdAIl+HPUqU7xyOUDKD0JWsHh9ap6yJG/jTs0tvA==", + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.22.tgz", + "integrity": "sha512-A5oRf2I02XDRu2NnqKo7Au5kUu244unvyoRMUsB+xvg/BOlsR+G/oyDFGkjbda1cJbeKm9eqposIXbA2ri5UrQ==", "license": "MIT", "dependencies": { "@expo/schema-utils": "^57.0.2", @@ -7653,10 +7651,11 @@ } }, "node_modules/expo-constants": { - "version": "57.0.18", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.18.tgz", - "integrity": "sha512-cLDwDOniPfuCZPHNY++cAY2ZkK97u5aobbBvilPK2LLmaWxR4ZpD31LWUIzUDLrwMPxLjmh1uURnbeYEf0Mivw==", + "version": "57.0.21", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.21.tgz", + "integrity": "sha512-D5fV2GELYGbNkoiEVZZp5zrUXZDaAx6ZzErDzGirm3jCjOhaE8iU/GyJiB0ZQE+K4Bg8gY82nIQ/RZR8CJLVjA==", "license": "MIT", + "peer": true, "dependencies": { "@expo/env": "~2.4.3" }, @@ -7754,6 +7753,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", "integrity": "sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -7764,9 +7764,9 @@ } }, "node_modules/expo-glass-effect": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-57.0.3.tgz", - "integrity": "sha512-aTh2J5eCxf+rSRMBJTnP8Ib5/k8DYFVDuEX1xqesN9F9aqTXocwMv/aQRVBNUs/iogDWhHIJz2pN97IsrrTcoA==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-57.0.4.tgz", + "integrity": "sha512-/AG2Kljbqfgxu+GchI2YFIbxiF9cPsIpCy4iNDzlRiZk9rvR6BwRfB6gfQVu3V2ExaoGf2h5IsEnqVI5MYNBAg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -7811,12 +7811,13 @@ } }, "node_modules/expo-linking": { - "version": "57.0.10", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.10.tgz", - "integrity": "sha512-RzRi4l4pIYzjgoCJKnqkO+lUI+M2YX6qgcMIQkIbLAXdcWrykBoKYwHGlmAkdFHQm89GaYcq329yfxG+c1hv8w==", + "version": "57.0.12", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.12.tgz", + "integrity": "sha512-oKLva5FOBx4txZa9LFu6RaHGAy1lFCyoOM91uU9H+gZi1iskjuSxV12GgS9H+yGGe2aSLufh6IfwSWrjEIsOuA==", "license": "MIT", + "peer": true, "dependencies": { - "expo-constants": "~57.0.18", + "expo-constants": "~57.0.21", "invariant": "^2.2.4" }, "peerDependencies": { @@ -7837,12 +7838,12 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "57.0.13", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.13.tgz", - "integrity": "sha512-Hj5NjZRlfccazhKab+wK19leRVUr1Y/PUERaRvegIbUSkq4GVxKq6lU9QHlKQyC16MdD/VS4iwsiLivB4o7lvQ==", + "version": "57.0.14", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.14.tgz", + "integrity": "sha512-dWql3NU2tvgfwkdZY3u9Sy3iWRA4IGDk91aOGZAJm3ZqudFxDRzAxlh8oQCVQ81Z+U9VmlUXw/4P9CbL33Ty0A==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^57.0.5", + "@expo/require-utils": "^57.0.6", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" @@ -7852,13 +7853,13 @@ } }, "node_modules/expo-modules-core": { - "version": "57.0.18", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.18.tgz", - "integrity": "sha512-ovB7C+GVQTlKAOIsOIHJVVXN9Mn0ljf2SYBS+afrRaGqq+HZEn1D3o+HReIVeH1NjCn/xDBb7I4HjldHs6luZA==", + "version": "57.0.21", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.21.tgz", + "integrity": "sha512-g0pf9/tQ6IdJWCzr6PDrEiYT8mTaOhMRnXm3T2XtSzJJaNYgnyyDonkkPN1wd2+PqojIBPhytqqzeLxjM5y/Wg==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", - "expo-modules-jsi": "~57.1.0", + "expo-modules-jsi": "~57.1.1", "invariant": "^2.2.4" }, "peerDependencies": { @@ -7873,24 +7874,24 @@ } }, "node_modules/expo-modules-jsi": { - "version": "57.1.0", - "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.1.0.tgz", - "integrity": "sha512-a5ckeHfnbYfonhcHGkM0EU/a0Keh+/OufXy/HyFS+spr5Ib0N4Oh1ZsSYFQhp5xdOUFkVofKRhfH+VmcccIIpA==", + "version": "57.1.1", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.1.1.tgz", + "integrity": "sha512-ssnwz3ZuIgCbZj5A3J6NlhQrXZojg+BjkeWrOWqVJhyMHlV/A/cRT7s986chKku97BFt6ucmDu6/BCzrV45Wcw==", "license": "MIT", "peerDependencies": { "react-native": "*" } }, "node_modules/expo-router": { - "version": "57.0.21", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.21.tgz", - "integrity": "sha512-r7UDLZU0gtVm8FW4nv2pxSl9TDhKlf2shcBbfp1T9STXJ3xYl9llLH701qQkICuXqqt3vGpOZ57GXC1ioVWg4g==", + "version": "57.0.25", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.25.tgz", + "integrity": "sha512-S/xWUGeVqMozG8gFJT0CHOVErZEbQnsAEAqlBIvHJSUZPH0hBcxy2kduUrV7ZDLNI4xCPIlOJ4SI29X/1z/hxw==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.4", - "@expo/metro-runtime": "^57.0.15", + "@expo/metro-runtime": "^57.0.16", "@expo/schema-utils": "^57.0.2", - "@expo/ui": "^57.0.18", + "@expo/ui": "^57.0.22", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -7898,7 +7899,7 @@ "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", - "expo-glass-effect": "^57.0.3", + "expo-glass-effect": "^57.0.4", "expo-server": "^57.0.3", "expo-symbols": "^57.0.3", "fast-deep-equal": "^3.1.3", @@ -7917,11 +7918,11 @@ }, "peerDependencies": { "@expo/log-box": "^57.0.4", - "@expo/metro-runtime": "^57.0.15", + "@expo/metro-runtime": "^57.0.16", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^57.0.18", - "expo-linking": "^57.0.10", + "expo-constants": "^57.0.21", + "expo-linking": "^57.0.12", "react": "*", "react-dom": "*", "react-native": "*", @@ -8056,14 +8057,15 @@ "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz", "integrity": "sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.16.0" } }, "node_modules/expo-sharing": { - "version": "57.0.19", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.19.tgz", - "integrity": "sha512-lZ9chd/kxalZkMaJnPxW6dEpj7hnlrikesiEV9KLS99VKgkfLgH+/pFvIfk0YwGJ1b1/eLGcZ7OfJ2jjSgFpNA==", + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.22.tgz", + "integrity": "sha512-IWmiz0Dnjd7ifsZ2Gg5XODxb6DQoJ4/H6FofW9bTL9gDjfX2+va8SV1BYVkFvJ8vKQoKR4HgkMCKDCZlrrbKkg==", "license": "MIT", "dependencies": { "@expo/config-plugins": "^57.0.9", @@ -8157,29 +8159,29 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "57.0.24", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.24.tgz", - "integrity": "sha512-CCBkvhPgAcrzJQ3daYbyOD6YuqKKkvOIyUfAbsUIvlpLomIlocYxFHYdmaC/KHqfCsbGFYSV/QKTSYshhSFNEA==", + "version": "57.0.28", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.28.tgz", + "integrity": "sha512-4969gk/B2JyP3kAv2ftpwU1f9dLG2j9TsQuYCTTjfnyRx0O49Jwu1O1mie2c3HmKXbwfNA6QTS+3i3sNetMTag==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~57.0.9", - "@expo/config-plugins": "~57.0.9", + "@expo/config": "~57.0.10", + "@expo/config-plugins": "~57.0.10", "@expo/devcert": "^1.2.1", "@expo/env": "~2.4.3", - "@expo/image-utils": "^0.11.5", + "@expo/image-utils": "^0.11.6", "@expo/inline-modules": "^0.1.7", "@expo/json-file": "^11.0.1", "@expo/log-box": "^57.0.4", "@expo/metro": "~56.0.2", - "@expo/metro-config": "~57.0.12", - "@expo/metro-file-map": "^57.0.3", + "@expo/metro-config": "~57.0.13", + "@expo/metro-file-map": "^57.0.4", "@expo/osascript": "^2.7.1", "@expo/package-manager": "^1.13.1", "@expo/plist": "^0.8.1", - "@expo/prebuild-config": "^57.0.16", - "@expo/require-utils": "^57.0.5", - "@expo/router-server": "^57.0.9", + "@expo/prebuild-config": "^57.0.17", + "@expo/require-utils": "^57.0.6", + "@expo/router-server": "^57.0.12", "@expo/schema-utils": "^57.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", @@ -8240,18 +8242,18 @@ } }, "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.9.tgz", - "integrity": "sha512-/PxRQozFesIyCJZOAtrQE8XcmcojNiL5ctPMQnbE4ojC2EJPu0zc7c0Y4PuXsoRxrZxm8Usw76/lCVrXcfTZ2w==", + "version": "57.0.12", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.12.tgz", + "integrity": "sha512-afP8Lu+ozEP/6qvLuxeBKoBVSeU+e0zwk+iYBUM6fBxGRniJQyG1HqpHQFr0ZEocuVxRjmq9eoCnspFuuzDQoA==", "license": "MIT", "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { - "@expo/metro-runtime": "^57.0.15", + "@expo/metro-runtime": "^57.0.16", "expo": "*", - "expo-constants": "^57.0.17", - "expo-font": "^57.0.3", + "expo-constants": "^57.0.21", + "expo-font": "^57.0.4", "expo-router": "*", "expo-server": "^57.0.3", "react": "*", @@ -8274,9 +8276,9 @@ } }, "node_modules/expo/node_modules/@expo/ws-tunnel": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", - "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.1.tgz", + "integrity": "sha512-mljsRffbOHzmSimYQbQu9h9NR3alwCCwEEouRGFL4QRTt8dNvsNtKEPa/5jHmtDLDtsh4xXsWKwzjN9BTpeXHQ==", "license": "MIT", "peerDependencies": { "ws": "^8.0.0" @@ -8341,10 +8343,11 @@ } }, "node_modules/expo/node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -8677,6 +8680,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -9980,6 +9984,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11549,9 +11554,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11572,9 +11574,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11595,9 +11594,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11618,9 +11614,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12327,9 +12320,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -13104,9 +13097,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "funding": [ { "type": "opencollective", @@ -13123,9 +13116,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -13342,6 +13335,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13361,6 +13355,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13413,6 +13408,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", "integrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", "license": "MIT", + "peer": true, "dependencies": { "@react-native/assets-registry": "0.86.3", "@react-native/codegen": "0.86.3", @@ -13501,6 +13497,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.32.0.tgz", "integrity": "sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", @@ -13527,6 +13524,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz", "integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==", "license": "MIT", + "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" @@ -13542,6 +13540,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -13566,6 +13565,7 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -13598,6 +13598,7 @@ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz", "integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.28.6", @@ -13658,7 +13659,6 @@ "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13674,6 +13674,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14464,9 +14465,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -15257,6 +15258,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15994,6 +15996,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 7e07815..339baaf 100644 --- a/package.json +++ b/package.json @@ -3,28 +3,28 @@ "main": "expo-router/entry", "version": "1.0.0-beta.28", "dependencies": { - "@expo/ui": "~57.0.18", + "@expo/ui": "~57.0.22", "@expo/vector-icons": "^15.0.2", "@noble/hashes": "^2.3.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.3.16", "base64-js": "^1.5.1", - "expo": "~57.0.22", + "expo": "~57.0.27", "expo-blur": "~57.0.3", - "expo-build-properties": "~57.0.17", + "expo-build-properties": "~57.0.22", "expo-clipboard": "~57.0.2", - "expo-constants": "~57.0.18", + "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", "expo-dev-client": "~57.0.19", "expo-device": "~57.0.2", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", - "expo-glass-effect": "~57.0.3", + "expo-glass-effect": "~57.0.4", "expo-image": "~57.0.5", - "expo-linking": "~57.0.10", - "expo-router": "~57.0.21", + "expo-linking": "~57.0.12", + "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", - "expo-sharing": "~57.0.19", + "expo-sharing": "~57.0.22", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-symbols": "~57.0.3", From fedfa2fb1d941fc91e07810f53a817b43e1a8df5 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 9 Oct 2026 09:33:47 +0100 Subject: [PATCH 16/16] fix(web): lock saved-PC changes across tabs and forget failed devices - Run PairingStore's read-modify-write of the saved-PC index inside a cross-tab Web Lock on the web, so two tabs saving different PCs no longer drop one of them; native keeps its single-instance queue through a pass-through lock - Forget a desktop's cached Web Bluetooth device when opening it fails, so a PC whose address changed is looked up again or chosen through the picker on the next attempt - Cover both with regression tests and describe them in docs/web.md Addresses the two P1 Greptile findings on #185. Co-Authored-By: Claude Opus 5.5 --- docs/web.md | 4 +-- src/storage/PairingStore.test.ts | 35 +++++++++++++++++++++ src/storage/PairingStore.ts | 9 ++++-- src/storage/storageLock.ts | 4 +++ src/storage/storageLock.web.test.ts | 19 +++++++++++ src/storage/storageLock.web.ts | 25 +++++++++++++++ src/transport/WebBluetoothTransport.test.ts | 25 ++++++++++++++- src/transport/WebBluetoothTransport.ts | 6 +++- 8 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 src/storage/storageLock.ts create mode 100644 src/storage/storageLock.web.test.ts create mode 100644 src/storage/storageLock.web.ts diff --git a/docs/web.md b/docs/web.md index ecb92a6..f5959d3 100644 --- a/docs/web.md +++ b/docs/web.md @@ -21,11 +21,11 @@ Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and Chr |---|---|---| | Transport | `ReactNativeBleTransport` (react-native-ble-plx) | `WebBluetoothTransport`, selected by `createBleTransport.web.ts` | | Discovery | Background scan lists every nearby PC | The browser's device picker returns one chosen PC per search. Closing the picker returns to idle (`scan_cancelled`) without showing an error. If the browser refuses to open the picker, because the tap was too long ago or Bluetooth is blocked for the site, the app explains this and asks you to try again. | -| Saved PCs | Rescans and matches the desktop ID | Reuses devices this site may already use (`navigator.bluetooth.getDevices()`), matching by desktop ID. Otherwise it asks once through the picker, which needs a tap. | +| Saved PCs | Rescans and matches the desktop ID | Reuses devices this site may already use (`navigator.bluetooth.getDevices()`), matching by desktop ID. Otherwise it asks once through the picker, which needs a tap. If a remembered device fails to open, for example after a Windows PC changes its Bluetooth address, it is forgotten, so the next attempt looks the PC up again or asks through the picker. | | Write size | Negotiated MTU (Android requests 517) | Fixed at 182 bytes (a 185-byte ATT MTU). The browser hides the negotiated MTU, and Switchify PC on macOS rejects long (offset) writes. | | GATT concurrency | Native transaction queue | One GATT operation at a time, because browsers reject overlapping operations | | Write cancellation | Cancels the native transaction | An in-flight write cannot be aborted, so cancelling waits up to one second for it to finish. That way disconnect cleanup (drag end, modifier release) still reaches the PC. Only a write that never finishes makes writes unavailable until the next connection, matching the native behaviour when cancellation fails. | -| Pairing secrets | Keychain / Keystore via `expo-secure-store` | AES-GCM ciphertext in `localStorage`, encrypted with a non-extractable key held in IndexedDB (`secretStorage.web.ts`). Clearing site data removes the pairing. | +| Pairing secrets | Keychain / Keystore via `expo-secure-store` | AES-GCM ciphertext in `localStorage`, encrypted with a non-extractable key held in IndexedDB (`secretStorage.web.ts`). Clearing site data removes the pairing. Saving, removing and tidying saved PCs holds a cross-tab Web Lock (`storageLock.web.ts`), so two open tabs cannot drop each other's PCs. | | Alerts | Native dialogs | `window.alert` and `window.confirm` (every app alert is a notice or a cancel plus one action) | | Announcements | `AccessibilityInfo` | A polite ARIA live region (`installPlatform.web.ts`) | | Diagnostics export | Share sheet | Text file download | diff --git a/src/storage/PairingStore.test.ts b/src/storage/PairingStore.test.ts index f396881..fb032ae 100644 --- a/src/storage/PairingStore.test.ts +++ b/src/storage/PairingStore.test.ts @@ -138,4 +138,39 @@ describe('PairingStore transactions', () => { expect(publicStorage.values.get(indexKey)).toBe(JSON.stringify([pc])); expect(publicStorage.values.get(`${indexKey}.default`)).toBe('pc-1'); }); + + it('keeps both PCs when two tabs save at the same time under the shared lock', async () => { + const publicStorage = new PublicMemory(); + const secretStorage = new SecretMemory(); + const slowGet = publicStorage.getItem; + publicStorage.getItem = async (key: string) => { const value = await slowGet(key); await new Promise((resolve) => setTimeout(resolve, 5)); return value; }; + let held: Promise = Promise.resolve(); + const sharedLock = async (_name: string, operation: () => Promise): Promise => { + const previous = held; + let release!: () => void; + held = new Promise((resolve) => { release = resolve; }); + await previous; + try { return await operation(); } finally { release(); } + }; + const firstTab = new PairingStore(publicStorage, secretStorage, sharedLock); + const secondTab = new PairingStore(publicStorage, secretStorage, sharedLock); + await Promise.all([ + firstTab.save({ ...pc, desktopId: 'pc-a' }, 'token-a'), + secondTab.save({ ...pc, desktopId: 'pc-b' }, 'token-b'), + ]); + const fresh = new PairingStore(publicStorage, secretStorage, sharedLock); + expect((await fresh.list()).map((saved) => saved.desktopId).sort()).toEqual(['pc-a', 'pc-b']); + }); + + it('runs every pairing change inside the storage lock', async () => { + const { publicStorage, secretStorage } = fixture(); + const names: string[] = []; + const lock = async (name: string, operation: () => Promise): Promise => { names.push(name); return operation(); }; + const store = new PairingStore(publicStorage, secretStorage, lock); + await store.list(); + await store.save({ ...pc, desktopId: 'pc-2' }, 'token-2'); + await store.setDefaultDesktopId('pc-2'); + await store.remove('pc-2'); + expect(names).toEqual(['pairings', 'pairings', 'pairings', 'pairings']); + }); }); diff --git a/src/storage/PairingStore.ts b/src/storage/PairingStore.ts index 19d8597..0da22d2 100644 --- a/src/storage/PairingStore.ts +++ b/src/storage/PairingStore.ts @@ -3,6 +3,7 @@ import * as Crypto from 'expo-crypto'; import * as SecureStore from 'expo-secure-store'; import { secretStorage as platformSecretStorage, type SecretStorage } from './secretStorage'; +import { withStorageLock } from './storageLock'; import type { PcPlatform } from '@/domain/protocol/types'; @@ -33,7 +34,11 @@ type PublicStorage = Pick = Promise.resolve(); - constructor(private readonly publicStorage: PublicStorage = AsyncStorage, private readonly secretStorage: SecretStorage = platformSecretStorage) {} + constructor( + private readonly publicStorage: PublicStorage = AsyncStorage, + private readonly secretStorage: SecretStorage = platformSecretStorage, + private readonly exclusive: (name: string, operation: () => Promise) => Promise = withStorageLock, + ) {} async getDeviceId(): Promise { if (this.secretStorage.getOrCreateItemAsync) { @@ -128,7 +133,7 @@ export class PairingStore implements PairingStorage { let release!: () => void; this.#storageQueue = new Promise((resolve) => { release = resolve; }); await previous.catch(() => undefined); - try { return await operation(); } + try { return await this.exclusive('pairings', operation); } finally { release(); } } } diff --git a/src/storage/storageLock.ts b/src/storage/storageLock.ts new file mode 100644 index 0000000..ea124db --- /dev/null +++ b/src/storage/storageLock.ts @@ -0,0 +1,4 @@ +/** Native apps have a single store instance, so the in-process queue is already exclusive. */ +export function withStorageLock(_name: string, operation: () => Promise): Promise { + return operation(); +} diff --git a/src/storage/storageLock.web.test.ts b/src/storage/storageLock.web.test.ts new file mode 100644 index 0000000..9517c9e --- /dev/null +++ b/src/storage/storageLock.web.test.ts @@ -0,0 +1,19 @@ +/** @jest-environment jsdom */ +import { withStorageLock } from './storageLock.web'; + +describe('web storage lock', () => { + afterEach(() => { Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined }); }); + + it('runs the operation under an exclusive, bounded cross-tab lock', async () => { + const request = jest.fn(async (_name: string, _options: { mode: string; signal: AbortSignal }, callback: () => Promise) => callback()); + Object.defineProperty(navigator, 'locks', { configurable: true, value: { request } }); + await expect(withStorageLock('pairings', async () => 'saved')).resolves.toBe('saved'); + expect(request).toHaveBeenCalledWith('switchify.remote.pairings', expect.objectContaining({ mode: 'exclusive', signal: expect.any(AbortSignal) }), expect.any(Function)); + }); + + it('refuses to change shared pairings without cross-tab locks', async () => { + const operation = jest.fn(async () => 'saved'); + await expect(withStorageLock('pairings', operation)).rejects.toThrow('unavailable'); + expect(operation).not.toHaveBeenCalled(); + }); +}); diff --git a/src/storage/storageLock.web.ts b/src/storage/storageLock.web.ts new file mode 100644 index 0000000..9e1330f --- /dev/null +++ b/src/storage/storageLock.web.ts @@ -0,0 +1,25 @@ +const LOCK_WAIT_MS = 10_000; + +type LockManager = { + request(name: string, options: { mode: 'exclusive'; signal: AbortSignal }, callback: () => Promise): Promise; +}; + +/** + * Every tab has its own store but shares the saved-PC index and tokens. A read, + * change and write of that shared state must hold one cross-tab lock, or two tabs + * saving at once can each drop the other's PC. + */ +export async function withStorageLock(name: string, operation: () => Promise): Promise { + const locks = typeof navigator === 'undefined' ? undefined : (navigator as Navigator & { locks?: LockManager }).locks; + if (!locks) throw new Error('Secure storage is unavailable in this browser.'); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), LOCK_WAIT_MS); + try { + return await locks.request(`switchify.remote.${name}`, { mode: 'exclusive', signal: controller.signal }, async () => { + clearTimeout(timer); + return await operation(); + }); + } finally { + clearTimeout(timer); + } +} diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index 3ce0310..b29e54a 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -56,7 +56,14 @@ class FakePeripheral extends EventTarget implements WebBluetoothDevice, WebBluet this.characteristics = Object.fromEntries(uuids.map((uuid) => [uuid, new FakeCharacteristic(this, uuid)])); } get gatt(): WebBluetoothServer { return this; } - async connect(): Promise { this.connects += 1; await this.connectGate; this.connected = true; return this; } + failConnect = false; + async connect(): Promise { + this.connects += 1; + await this.connectGate; + if (this.failConnect) throw Object.assign(new Error('Connection failed.'), { name: 'NetworkError' }); + this.connected = true; + return this; + } disconnect(): void { if (!this.connected) return; this.connected = false; @@ -548,4 +555,20 @@ describe('WebBluetoothTransport', () => { await flush(); expect(onError.mock.calls[0]?.[0]).not.toBeInstanceOf(BluetoothDeviceSelectionCancelledError); }); + + it('forgets a cached device that fails so the next attempt finds the PC again', async () => { + const bluetooth = new FakeBluetooth(); + const previous = new FakePeripheral('web-old', status('desk-1')); + const replacement = new FakePeripheral('web-new', status('desk-1')); + bluetooth.chooser = async () => previous; + const transport = new WebBluetoothTransport(() => bluetooth); + await transport.resolveAndConnect('desk-1'); + await transport.disconnect(); + previous.failConnect = true; + bluetooth.chooser = async () => replacement; + await expect(transport.resolveAndConnect('desk-1')).rejects.toThrow(); + expect(bluetooth.requests).toBe(1); + await expect(transport.resolveAndConnect('desk-1')).resolves.toMatchObject({ desktopId: 'desk-1', peripheralId: 'web-new' }); + expect(bluetooth.requests).toBe(2); + }); }); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index 1738804..acfdfd5 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -174,9 +174,10 @@ export class WebBluetoothTransport implements BleTransport { await this.#disconnectExceptDiscovery(desktopId, operation); if (operation !== this.#operation) throw cancelled(); this.#recordStage('resolution', 'started', operation); + let device: WebBluetoothDevice | null = null; try { const known = this.#desktopDevices.get(desktopId); - let device = known ? this.#devices.get(known) ?? null : null; + device = known ? this.#devices.get(known) ?? null : null; device ??= await this.#findRememberedDesktop(desktopId, operation); if (operation !== this.#operation) throw cancelled(); if (!device) { @@ -191,6 +192,9 @@ export class WebBluetoothTransport implements BleTransport { return this.#describe(device, status); } catch (error) { this.#recordStage('resolution', 'failed', operation); + // A PC can change its Bluetooth address (Windows rotates it). Forget a device that + // failed so the next attempt looks it up again or asks through the picker. + if (operation === this.#operation && device && this.#desktopDevices.get(desktopId) === device.id) this.#desktopDevices.delete(desktopId); if (operation === this.#operation) await this.disconnect(); throw error; }