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/accessibility.md b/docs/accessibility.md index e4d86b5..94548fd 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -1,5 +1,15 @@ # 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 +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/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..f5959d3 --- /dev/null +++ b/docs/web.md @@ -0,0 +1,101 @@ +# 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. 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. 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. 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 | +| 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). + +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, +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 + +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 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: + +- 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/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", 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/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index f1e9190..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'; @@ -11,8 +11,8 @@ 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 { mergePcList, pcListAction, type PcListItem } from '@/connection/pcList'; +import { bluetoothPermissionRecoveryMessage, bluetoothUnsupportedMessage } from '@/connection/permissions'; +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,10 +34,10 @@ 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} + {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/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/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/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/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..fe8119b 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 { 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'; @@ -80,13 +80,14 @@ 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 { 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'); @@ -94,8 +95,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(error instanceof BluetoothPickerBlockedError ? BLUETOOTH_PICKER_BLOCKED_MESSAGE : 'Could not connect to this PC.', operation); } } @@ -140,8 +143,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(error instanceof BluetoothPickerBlockedError ? BLUETOOTH_PICKER_BLOCKED_MESSAGE : 'Could not find this PC nearby.', operation); } } @@ -492,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 { @@ -545,14 +550,34 @@ 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; + } + 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'); 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..7711386 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 { 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'; @@ -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) => { @@ -64,12 +66,70 @@ 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'); 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('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/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 }; +} 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..5253cd2 100644 --- a/src/diagnostics/DiagnosticLog.ts +++ b/src/diagnostics/DiagnosticLog.ts @@ -6,6 +6,12 @@ const connectionStages = { priority: 'Request Android connection priority (optional)', 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', + response_characteristic: 'Discover PC read-response characteristic', probe_connect: 'Connect for discovery status', probe_services: 'Discover status services', status_read: 'Read discovery status', @@ -21,6 +27,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.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/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/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..58f7667 --- /dev/null +++ b/src/navigation/tabSceneLayout.web.tsx @@ -0,0 +1,25 @@ +import { type ReactElement, useCallback, useSyncExternalStore } 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 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}; +} + +export const tabSceneLayout = ({ children, navigation }: { children: ReactElement; navigation: TabSceneNavigation }): ReactElement => + {children}; 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()} { + 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 new file mode 100644 index 0000000..3049d03 --- /dev/null +++ b/src/platform/installPlatform.web.ts @@ -0,0 +1,92 @@ +import { AccessibilityInfo, Alert } from 'react-native'; + +const REGION_ID = 'switchify-live-region'; +let installed = false; + +function liveRegion(): HTMLElement | null { + if (typeof document === 'undefined' || !document.body) return null; + const existing = document.getElementById(REGION_ID); + if (existing) return existing; + const region = document.createElement('div'); + region.id = REGION_ID; + region.setAttribute('role', 'status'); + region.setAttribute('aria-live', 'polite'); + region.setAttribute('aria-atomic', 'true'); + Object.assign(region.style, { + position: 'absolute', + width: '1px', + height: '1px', + margin: '-1px', + padding: '0', + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + whiteSpace: 'nowrap', + border: '0', + }); + document.body.appendChild(region); + return region; +} + +let pending: string[] = []; +let flushing = false; + +function announce(message: string): void { + if (!message) return; + pending.push(message); + if (flushing) return; + flushing = true; + const next = () => { + 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); + // 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?.(); +}; + +/** 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); + // 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/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/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/storage/PairingStore.test.ts b/src/storage/PairingStore.test.ts index e4df379..fb032ae 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; @@ -127,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 0c1a67f..0da22d2 100644 --- a/src/storage/PairingStore.ts +++ b/src/storage/PairingStore.ts @@ -2,6 +2,9 @@ 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 { withStorageLock } from './storageLock'; + import type { PcPlatform } from '@/domain/protocol/types'; const INDEX_KEY = 'switchify.remote.pairings.v1'; @@ -27,14 +30,20 @@ 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, + private readonly exclusive: (name: string, operation: () => Promise) => Promise = withStorageLock, + ) {} 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()}`; @@ -124,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/secretStorage.ts b/src/storage/secretStorage.ts new file mode 100644 index 0000000..f491fac --- /dev/null +++ b/src/storage/secretStorage.ts @@ -0,0 +1,7 @@ +import * as SecureStore from 'expo-secure-store'; + +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 new file mode 100644 index 0000000..7ef61bc --- /dev/null +++ b/src/storage/secretStorage.web.test.ts @@ -0,0 +1,191 @@ +/** @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; + abortCommit = 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) => { + 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; + if (!this.stores.has('keys')) request.onupgradeneeded?.(); + request.onsuccess?.(); + }, 0); + return request; + } + #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?.(); settled(false); } + catch (error) { request.error = error as Error; request.onerror?.(); settled(true); } + }, 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' }); + if (!this.abortCommit) 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 }); + 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'); + 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('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 new file mode 100644 index 0000000..f08a5ee --- /dev/null +++ b/src/storage/secretStorage.web.ts @@ -0,0 +1,162 @@ +import type * as SecureStore from 'expo-secure-store'; + +export type SecretStorage = Pick & { + getOrCreateItemAsync?: (name: string, create: () => string) => Promise; +}; + +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 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 unavailable(); +} + +function request(operation: IDBRequest): Promise { + return new Promise((resolve, reject) => { + operation.onsuccess = () => resolve(operation.result); + operation.onerror = () => reject(operation.error ?? unavailable()); + }); +} + +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); + 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 storedKey(database); + if (existing) return existing; + const created = await window.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + try { + // 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. + const raced = await storedKey(database); + if (raced) return raced; + throw error; + } + } 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; +} + +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 parsed = parse(stored); + if (!parsed) return null; + const secret = await key(); + try { + 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 (error) { + if (typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'OperationError') return null; + throw error; + } +} + +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}`); +} + +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/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/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.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/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..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; @@ -18,3 +20,15 @@ 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.'); } +} + +/** 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 new file mode 100644 index 0000000..b29e54a --- /dev/null +++ b/src/transport/WebBluetoothTransport.test.ts @@ -0,0 +1,574 @@ +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, + 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; + discovered: string[] = []; + 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(); + 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; } + 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; + 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) => { + 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; + }); + }, + }; + } + 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 < 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')); + 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')); + 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')); + 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 { + 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(); + 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('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; + 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(); + const cancelling = transport.cancelPendingWrites(); + await expect(queued).rejects.toThrow('cancelled'); + release(); + await inFlight; + 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(); + } + }); + + 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('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(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); + }); + + 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 new file mode 100644 index 0000000..acfdfd5 --- /dev/null +++ b/src/transport/WebBluetoothTransport.ts @@ -0,0 +1,594 @@ +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, BluetoothPickerBlockedError, 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; + +/** + * 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; + 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; done: Promise }; + +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 errorName(error: unknown): unknown { + return typeof error === 'object' && error !== null ? (error as { name?: unknown }).name : undefined; +} + +function isPickerOutcome(error: unknown): error is Error { + return error instanceof BluetoothDeviceSelectionCancelledError || error instanceof BluetoothPickerBlockedError; +} + +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(); + /** 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(); + #discoveredProbe: { device: WebBluetoothDevice; server: WebBluetoothServer; service: WebBluetoothService; desktopId: string; attempt: number; retained: boolean } | null = null; + #queue: Pending[] = []; + #running: Pending | null = null; + #queueGeneration = 0; + #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, + private readonly now: () => number = Date.now, + ) {} + + 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 device = await this.#choose(); + if (!active || operation !== this.#operation) return; + probe = device; + const desktop = await this.#readDesktop(device, operation); + if (!active || operation !== this.#operation) return; + if (!desktop) throw new Error('Bluetooth discovery status is invalid.'); + onDesktop(desktop); + })().catch((error: unknown) => { + if (!active || operation !== this.#operation) return; + onError(isPickerOutcome(error) ? error : new Error('Bluetooth discovery failed.')); + }); + return () => { + if (!active) return; + active = false; + if (operation === this.#operation) this.#operation += 1; + const attempt = probe ? this.#probes.get(probe) : undefined; + 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 { + 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(); + 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); + } + + async resolveAndConnect(desktopId: string): Promise { + const operation = ++this.#operation; + 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); + device = known ? this.#devices.get(known) ?? null : null; + device ??= await this.#findRememberedDesktop(desktopId, operation); + if (operation !== this.#operation) throw cancelled(); + if (!device) { + device = await this.#choose(); + this.#devices.set(device.id, device); + } + 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.'); + this.#recordStage('resolution', 'succeeded', operation); + 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; + } + } + + async disconnect(): Promise { + await this.#disconnectExceptDiscovery(); + } + + 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]) { + 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) { + this.#owners.delete(session.device.id); + 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.'); + } + + /** + * 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)); + 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 { + 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); + 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 = 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.'); + this.#desktopDevices.set(status.desktopId, device.id); + this.#session = { device, server, characteristics, readReplies }; + this.#writePoisoned = false; + return status; + } catch (error) { + this.#closeIfOwner(device, attempt); + throw error; + } + } + + /** + * 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; + 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, attempt); + }, () => 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; + } + } + + 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(); + 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 = 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); + 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. */ + async #readDesktop(device: WebBluetoothDevice, operation: number, timeoutMs = this.timeoutMs): Promise { + 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, 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()); + if (operation !== this.#operation) throw cancelled(); + // 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); + this.#recordStage('status_parse', status ? 'succeeded' : 'failed', operation); + if (!status) { + 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); + } catch (error) { + this.#releaseProbe(device, attempt); + throw error; + } + } + + async #rememberedDevices(): Promise { + const bluetooth = this.bluetooth(); + if (!bluetooth?.getDevices) return []; + try { + const devices = await bluetooth.getDevices(); + devices.forEach((device) => this.#devices.set(device.id, device)); + return devices; + } catch { + return []; + } + } + + /** 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) { + const remaining = deadline - this.now(); + if (operation !== this.#operation || remaining <= 0) return null; + try { + const desktop = await this.#readDesktop(device, operation, remaining); + if (desktop?.desktopId === desktopId) return device; + 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. + } + } + 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, 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); + } + + #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); + 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) => { + 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(); + }); + } + + #drain(): void { + 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; + 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); + } + + #cancellationTimeout(): number { return Math.min(1_000, this.timeoutMs); } + + #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); +}