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/web.md b/docs/web.md index 7dbb34c..ecb92a6 100644 --- a/docs/web.md +++ b/docs/web.md @@ -34,10 +34,68 @@ Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and Chr The protocol, framing limits, authentication and pairing approval are unchanged. See [protocol compatibility](protocol-compatibility.md). +First-time browser device identity creation uses an exclusive Web Lock, so tabs +share one persisted identity. Waiting for a lock is bounded to five seconds. If +locks are unavailable or persistence fails, initialization fails safely rather +than using an unsaved identity. Existing encrypted identities remain readable. +This keeps the existing keys, ciphertext format and native storage path unchanged. + +Connection cancellation includes initial cleanup: a stopped or replaced attempt +must not reopen the picker or begin another connection after cleanup completes. + +## Typing and probe recovery + +Keyboard Enter in live typing sends one Enter, clears only after acknowledgement, +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/components/ActionButton.tsx b/src/components/ActionButton.tsx index 03dadbe..87cba71 100644 --- a/src/components/ActionButton.tsx +++ b/src/components/ActionButton.tsx @@ -1,6 +1,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import type { ComponentProps, Ref } from 'react'; -import { ActivityIndicator, Animated, Pressable, Text, View, type View as NativeView } from 'react-native'; +import { ActivityIndicator, Animated, Platform, Pressable, Text, View, type View as NativeView } from 'react-native'; +import { webControlAccessibility } from './webControlAccessibility'; import { useTheme } from '@/theme/ThemeContext'; import { usePressScale } from './usePressScale'; @@ -14,7 +15,7 @@ export function ActionButton({ label, onPress, disabled = false, busy = false, s const background = resolvedTone === 'primary' ? colors.brand : resolvedTone === 'danger' ? colors.dangerTint : resolvedTone === 'secondary' ? colors.surfaceRaised : 'transparent'; const foreground = resolvedTone === 'primary' ? colors.onBrand : resolvedTone === 'danger' ? colors.danger : resolvedTone === 'tertiary' ? colors.brandText : colors.text; return ( - ({ alignItems: 'center', backgroundColor: pressed && !disabled ? (resolvedTone === 'primary' ? colors.brandPressed : colors.surfacePressed) : background, borderColor: resolvedTone === 'tertiary' ? 'transparent' : resolvedTone === 'danger' ? colors.danger : colors.border, borderRadius: radii.md, borderWidth: resolvedTone === 'primary' ? 0 : 1, justifyContent: 'center', minHeight: 52, opacity: disabled ? 0.4 : 1, paddingHorizontal: spacing.lg })}> + ({ alignItems: 'center', backgroundColor: pressed && !disabled ? (resolvedTone === 'primary' ? colors.brandPressed : colors.surfacePressed) : background, borderColor: resolvedTone === 'tertiary' ? 'transparent' : resolvedTone === 'danger' ? colors.danger : colors.border, borderRadius: radii.md, borderWidth: resolvedTone === 'primary' ? 0 : 1, justifyContent: 'center', minHeight: 52, opacity: disabled ? 0.4 : 1, paddingHorizontal: spacing.lg })}> {busy ? : icon ? : null} {label} diff --git a/src/components/ControlButton.tsx b/src/components/ControlButton.tsx index 431b54a..bc7a225 100644 --- a/src/components/ControlButton.tsx +++ b/src/components/ControlButton.tsx @@ -2,6 +2,7 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import type { ComponentProps, Ref } from "react"; import { Animated, + Platform, Pressable, Text, type AccessibilityRole, @@ -9,6 +10,7 @@ import { } from "react-native"; import { useTheme } from "@/theme/ThemeContext"; import { usePressScale } from "./usePressScale"; +import { webControlAccessibility } from './webControlAccessibility'; type IconName = ComponentProps["name"]; @@ -17,7 +19,7 @@ export function ControlButton({ accessibilityLabel = label, hint, onPress, - selected = false, + selected, disabled = false, danger = false, role = "button", @@ -60,7 +62,8 @@ export function ControlButton({ accessibilityRole={role} accessibilityLabel={accessibilityLabel} accessibilityHint={hint} - accessibilityState={{ selected, disabled }} + accessibilityState={{ selected: selected ?? false, disabled }} + {...webControlAccessibility(Platform.OS, { selected, disabled, role })} disabled={disabled} onPress={onPress} onPressIn={press.pressIn} diff --git a/src/components/IconButton.tsx b/src/components/IconButton.tsx index 79bc081..146bdc2 100644 --- a/src/components/IconButton.tsx +++ b/src/components/IconButton.tsx @@ -1,14 +1,15 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import type { ComponentProps } from "react"; -import { Animated, Pressable } from "react-native"; +import { Animated, Platform, Pressable } from "react-native"; import { useTheme } from "@/theme/ThemeContext"; import { usePressScale } from "./usePressScale"; +import { webControlAccessibility } from './webControlAccessibility'; export function IconButton({ icon, accessibilityLabel, hint, - selected = false, + selected, disabled = false, onPress, }: { @@ -34,7 +35,8 @@ export function IconButton({ accessibilityRole="button" accessibilityLabel={accessibilityLabel} accessibilityHint={hint} - accessibilityState={{ selected, disabled }} + accessibilityState={{ selected: selected ?? false, disabled }} + {...webControlAccessibility(Platform.OS, { selected, disabled })} disabled={disabled} onPress={onPress} onPressIn={press.pressIn} diff --git a/src/components/SelectorField.tsx b/src/components/SelectorField.tsx index 7044ef5..56e73df 100644 --- a/src/components/SelectorField.tsx +++ b/src/components/SelectorField.tsx @@ -7,6 +7,7 @@ import { AppText } from './AppText'; import { ControlButton } from './ControlButton'; import { focusAccessibilityTarget } from './accessibilityFocus'; import { useTheme } from '@/theme/ThemeContext'; +import { webControlAccessibility } from './webControlAccessibility'; export type SelectorOption = { key: T; label: string; disabled?: boolean }; @@ -91,7 +92,7 @@ export function SelectorField({ label, options, selec }; return <> - { setSelectionError(null); setVisible(true); }} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed ? colors.surfacePressed : colors.surfaceRaised, borderColor: colors.border, borderRadius: radii.lg, borderWidth: 1, flexDirection: 'row', gap: spacing.sm, minHeight: 48, padding: spacing.md })}> + { setSelectionError(null); setVisible(true); }} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed ? colors.surfacePressed : colors.surfaceRaised, borderColor: colors.border, borderRadius: radii.lg, borderWidth: 1, flexDirection: 'row', gap: spacing.sm, minHeight: 48, padding: spacing.md })}> {label}: {selected?.label ?? ''} diff --git a/src/components/webControlAccessibility.ts b/src/components/webControlAccessibility.ts new file mode 100644 index 0000000..6bb75eb --- /dev/null +++ b/src/components/webControlAccessibility.ts @@ -0,0 +1,19 @@ +/** Native state/value props are not forwarded by React Native Web. */ +export function webControlAccessibility(platform: string, { label, value, selected, disabled, busy, expanded, role = 'button' }: { + label?: string; + value?: string; + selected?: boolean | undefined; + disabled?: boolean; + busy?: boolean; + expanded?: boolean; + role?: string; +}) { + if (platform !== 'web') return {}; + return { + ...(label !== undefined && value !== undefined ? { 'aria-label': `${label}: ${value}` } : {}), + ...(selected !== undefined ? role === 'button' ? { 'aria-pressed': selected } : { 'aria-selected': selected } : {}), + ...(disabled !== undefined ? { 'aria-disabled': disabled } : {}), + ...(busy !== undefined ? { 'aria-busy': busy } : {}), + ...(expanded !== undefined ? { 'aria-expanded': expanded } : {}), + }; +} diff --git a/src/components/webControlAccessibility.web.test.js b/src/components/webControlAccessibility.web.test.js new file mode 100644 index 0000000..403b165 --- /dev/null +++ b/src/components/webControlAccessibility.web.test.js @@ -0,0 +1,35 @@ +/** @jest-environment jsdom */ +/* global describe, it, expect */ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server.node'; +import Pressable from 'react-native-web/dist/cjs/exports/Pressable'; +import { webControlAccessibility } from './webControlAccessibility'; + +function render(props) { + const host = document.createElement('div'); + host.innerHTML = renderToStaticMarkup(createElement(Pressable, { + accessibilityRole: 'button', accessibilityLabel: 'Surface', + disabled: props.disabled, + ...webControlAccessibility('web', props), + })); + return host.querySelector('button'); +} + +describe('browser control accessibility', () => { + it('includes the complete current value in the accessible name', () => { + const button = render({ label: 'Surface', value: 'Typing', expanded: true }); + expect(button.getAttribute('aria-label')).toBe('Surface: Typing'); + expect(button.getAttribute('aria-expanded')).toBe('true'); + expect(button.hasAttribute('aria-pressed')).toBe(false); + }); + it.each([true, false])('exposes toggle state %s and disabled/busy state', (selected) => { + const button = render({ selected, disabled: true, busy: true }); + expect(button.getAttribute('aria-pressed')).toBe(String(selected)); + expect(button.getAttribute('aria-disabled')).toBe('true'); + expect(button.getAttribute('aria-busy')).toBe('true'); + expect(button.hasAttribute('aria-selected')).toBe(false); + }); + it.each(['android', 'ios'])('does not override native props on %s', (platform) => { + expect(webControlAccessibility(platform, { label: 'Surface', value: 'Mouse', selected: true })).toEqual({}); + }); +}); diff --git a/src/connection/ConnectionManager.ts b/src/connection/ConnectionManager.ts index f240c9b..fe8119b 100644 --- a/src/connection/ConnectionManager.ts +++ b/src/connection/ConnectionManager.ts @@ -85,8 +85,9 @@ export class ConnectionManager { async connect(desktop: DiscoveredDesktop): Promise { const operation = ++this.#operation; + const retained = !this.#client && this.transport.retainDiscoveredConnection?.(desktop.desktopId) === true; this.#scanStop?.(); this.#scanStop = null; - await this.#teardownConnection(); + await this.#teardownConnection(retained); if (!this.#current(operation)) return; this.#set({ kind: 'connecting', desktop }); this.diagnostics.add('connecting'); @@ -496,14 +497,14 @@ export class ConnectionManager { this.#set({ kind: 'failed', message, saved }); } - async #teardownConnection(): Promise { + async #teardownConnection(preserveDiscovery = false): Promise { this.#cancelProfileRecovery(); this.#cancelHealthTimer(); this.#disconnectStop?.(); this.#disconnectStop = null; const client = this.#client; this.#client = null; this.#token = null; if (client) await client.close(); - await this.transport.disconnect().catch(() => undefined); + if (!preserveDiscovery) await this.transport.disconnect().catch(() => undefined); } #scheduleHealth(delay = 5_000): void { diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts index 51adf82..7711386 100644 --- a/src/connection/connection.test.ts +++ b/src/connection/connection.test.ts @@ -66,6 +66,27 @@ const waitFor = async (condition: () => boolean): Promise => { }; describe('connection lifecycle', () => { + it('claims a discovered connection before stopping the scan and skips intermediate disconnect', async () => { + const transport = new FakeTransport(); + const manager = new ConnectionManager(transport, new FakeStorage(), new DiagnosticLog(), async () => true); + const desktop = { ...pc('desk-1'), rssi: null }; + await manager.scan(); + const disconnect = jest.spyOn(transport, 'disconnect'); + const retained = jest.fn(() => { + expect(transport.scanStops).toBe(0); + return true; + }); + Object.assign(transport, { retainDiscoveredConnection: retained }); + transport.resolveGate = new Promise(() => undefined); + void manager.connect(desktop); + await waitFor(() => transport.resolveDesktopIds.length === 1); + expect(retained).toHaveBeenCalledWith('desk-1'); + expect(transport.scanStops).toBe(1); + expect(disconnect).not.toHaveBeenCalled(); + await manager.disconnect(); + expect(disconnect).toHaveBeenCalled(); + }); + it('matches the Android pairing verification algorithm', () => { expect(pairingVerificationCode('desktop-1', 'device-1', 'nonce-1')).toBe('215918'); expect(pairingVerificationCode('0:0:1280:720:1.5', 'android-device-id', 'random-request-nonce')).toBe('735258'); diff --git a/src/diagnostics/DiagnosticLog.ts b/src/diagnostics/DiagnosticLog.ts index 549f99c..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', 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..f396881 100644 --- a/src/storage/PairingStore.test.ts +++ b/src/storage/PairingStore.test.ts @@ -34,6 +34,17 @@ function fixture() { } describe('PairingStore transactions', () => { + it('uses platform atomic identity initialization when available', async () => { + const secrets = new SecretMemory(); + const atomic = jest.fn(async (_name: string, _create: () => string) => 'shared-device'); + const storage = Object.assign(secrets, { getOrCreateItemAsync: atomic }); + const first = new PairingStore(new PublicMemory(), storage); + const second = new PairingStore(new PublicMemory(), storage); + expect(await Promise.all([first.getDeviceId(), second.getDeviceId()])).toEqual(['shared-device', 'shared-device']); + expect(atomic).toHaveBeenCalledWith('switchify.remote.device-id.v1', expect.any(Function)); + expect(secrets.values.size).toBe(0); + }); + it('rolls back a token update when saving the public index fails', async () => { const { store, publicStorage } = fixture(); publicStorage.failSet = 1; diff --git a/src/storage/PairingStore.ts b/src/storage/PairingStore.ts index 888ebda..19d8597 100644 --- a/src/storage/PairingStore.ts +++ b/src/storage/PairingStore.ts @@ -36,6 +36,9 @@ export class PairingStore implements PairingStorage { constructor(private readonly publicStorage: PublicStorage = AsyncStorage, private readonly secretStorage: SecretStorage = platformSecretStorage) {} async getDeviceId(): Promise { + if (this.secretStorage.getOrCreateItemAsync) { + return this.secretStorage.getOrCreateItemAsync(DEVICE_ID_KEY, () => `remote-${Crypto.randomUUID()}`); + } const existing = await this.secretStorage.getItemAsync(DEVICE_ID_KEY); if (existing) return existing; const created = `remote-${Crypto.randomUUID()}`; diff --git a/src/storage/secretStorage.ts b/src/storage/secretStorage.ts index 08ee111..f491fac 100644 --- a/src/storage/secretStorage.ts +++ b/src/storage/secretStorage.ts @@ -1,5 +1,7 @@ import * as SecureStore from 'expo-secure-store'; -export type SecretStorage = Pick; +export type SecretStorage = Pick & { + getOrCreateItemAsync?: (name: string, create: () => string) => Promise; +}; export const secretStorage: SecretStorage = SecureStore; diff --git a/src/storage/secretStorage.web.test.ts b/src/storage/secretStorage.web.test.ts index a17f255..7ef61bc 100644 --- a/src/storage/secretStorage.web.test.ts +++ b/src/storage/secretStorage.web.test.ts @@ -70,9 +70,73 @@ beforeEach(() => { Object.defineProperty(window, 'indexedDB', { configurable: true, value: indexedDb }); Object.defineProperty(window, 'crypto', { configurable: true, value: webcrypto }); Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true }); + let queue: Promise = Promise.resolve(); + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { + request: (_name: string, _options: unknown, callback: () => Promise) => { + const next = queue.catch(() => undefined).then(callback); + queue = next; + return next; + }, + } }); }); describe('web secret storage', () => { + it('creates one persistent identity across independent browser instances', async () => { + const first = load().secretStorage; + const second = load().secretStorage; + const createFirst = jest.fn(() => 'remote-one'); + const createSecond = jest.fn(() => 'remote-two'); + const identities = await Promise.all([ + first.getOrCreateItemAsync!('device-id', createFirst), + second.getOrCreateItemAsync!('device-id', createSecond), + ]); + expect(identities[0]).toBe(identities[1]); + expect(createFirst.mock.calls.length + createSecond.mock.calls.length).toBe(1); + expect(await load().secretStorage.getItemAsync('device-id')).toBe(identities[0]); + expect(Object.values({ ...window.localStorage }).join(' ')).not.toContain(identities[0]); + }); + + it('preserves an existing identity without requiring a lock', async () => { + const storage = load().secretStorage; + await storage.setItemAsync('device-id', 'existing'); + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: undefined }); + const create = jest.fn(() => 'replacement'); + expect(await storage.getOrCreateItemAsync!('device-id', create)).toBe('existing'); + expect(create).not.toHaveBeenCalled(); + }); + + it('fails safely without cross-tab locks rather than racing a new identity', async () => { + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: undefined }); + const storage = load().secretStorage; + const create = jest.fn(() => 'unsafe'); + await expect(storage.getOrCreateItemAsync!('device-id', create)).rejects.toThrow('unavailable'); + expect(create).not.toHaveBeenCalled(); + expect(await storage.getItemAsync('device-id')).toBeNull(); + }); + + it('bounds waiting for a lock held by another tab', async () => { + jest.useFakeTimers(); + try { + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { + request: (_name: string, options: { signal: AbortSignal }) => new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new Error('lock aborted'))); + }), + } }); + const create = jest.fn(() => 'late'); + const pending = load().secretStorage.getOrCreateItemAsync!('device-id', create); + const rejected = expect(pending).rejects.toThrow('unavailable'); + await jest.advanceTimersByTimeAsync(5_000); + await rejected; + expect(create).not.toHaveBeenCalled(); + } finally { jest.useRealTimers(); } + }); + + it('does not return an identity when persistence fails', async () => { + indexedDb.abortCommit = true; + await expect(load().secretStorage.getOrCreateItemAsync!('device-id', () => 'unsaved')).rejects.toThrow('unavailable'); + expect(window.localStorage.length).toBe(0); + }); + it('round-trips a secret without storing it in plain text', async () => { const { secretStorage } = load(); await secretStorage.setItemAsync('switchify.remote.token.pc-1', 'secret-token'); diff --git a/src/storage/secretStorage.web.ts b/src/storage/secretStorage.web.ts index 827bc42..f08a5ee 100644 --- a/src/storage/secretStorage.web.ts +++ b/src/storage/secretStorage.web.ts @@ -1,6 +1,8 @@ import type * as SecureStore from 'expo-secure-store'; -export type SecretStorage = Pick; +export type SecretStorage = Pick & { + getOrCreateItemAsync?: (name: string, create: () => string) => Promise; +}; const DATABASE = 'switchify-remote-secrets'; const STORE = 'keys'; @@ -133,4 +135,28 @@ async function deleteItemAsync(name: string): Promise { window.localStorage.removeItem(`${VALUE_PREFIX}${name}`); } -export const secretStorage = { getItemAsync, setItemAsync, deleteItemAsync } as SecretStorage; +async function getOrCreateItemAsync(name: string, create: () => string): Promise { + const existing = await getItemAsync(name); + if (existing) return existing; + // The identity read, generation and encrypted write must share a cross-tab lock. + // A per-tab promise only protects one instance and can invalidate another pairing. + if (!window.navigator.locks) throw unavailable(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5_000); + try { + return await window.navigator.locks.request(`switchify.remote.initialize.${name}`, { mode: 'exclusive', signal: controller.signal }, async () => { + clearTimeout(timer); + const raced = await getItemAsync(name); + if (raced) return raced; + const created = create(); + await setItemAsync(name, created); + return created; + }); + } catch { + throw unavailable(); + } finally { + clearTimeout(timer); + } +} + +export const secretStorage: SecretStorage = { getItemAsync, setItemAsync, deleteItemAsync, getOrCreateItemAsync }; diff --git a/src/transport/BleTransport.ts b/src/transport/BleTransport.ts index e5fc6cf..495b37c 100644 --- a/src/transport/BleTransport.ts +++ b/src/transport/BleTransport.ts @@ -7,6 +7,8 @@ export type BleAvailability = 'ready' | 'unauthorized' | 'poweredOff' | 'unsuppo export interface BleTransport { availability(): Promise; scan(onDesktop: (desktop: DiscoveredDesktop) => void, onError: (error: Error) => void): Unsubscribe; + /** Claim a completed discovery connection before stopping its scan. Disconnect still releases it. */ + retainDiscoveredConnection?(desktopId: string): boolean; connect(peripheralId: string): Promise; resolveAndConnect(desktopId: string): Promise; disconnect(): Promise; diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index bac7306..3ce0310 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -1,6 +1,7 @@ import { fromByteArray, toByteArray } from 'base64-js'; import { BLE_UUIDS } from '@/domain/protocol/constants'; +import { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; import { BluetoothDeviceSelectionCancelledError, BluetoothPickerBlockedError } from './BleTransport'; import { REMEMBERED_DEVICE_BUDGET_MS, @@ -45,6 +46,7 @@ class FakePeripheral extends EventTarget implements WebBluetoothDevice, WebBluet busy = false; overlapping = false; connects = 0; + discovered: string[] = []; writeGate: Promise | null = null; connectGate: Promise | null = null; characteristics: Record; @@ -64,9 +66,13 @@ class FakePeripheral extends EventTarget implements WebBluetoothDevice, WebBluet if (uuid !== BLE_UUIDS.service) throw new Error('missing service'); return { getCharacteristic: async (characteristic: string) => { - const found = this.characteristics[characteristic]; - if (!found) throw new Error('missing characteristic'); - return found; + this.discovered.push(characteristic); + if (this.busy) throw new Error('GATT operation already in progress'); + return this.operation(() => { + const found = this.characteristics[characteristic]; + if (!found) throw new Error('missing characteristic'); + return found; + }); }, }; } @@ -100,6 +106,191 @@ class FakeBluetooth implements WebBluetooth { const flush = async () => { for (let index = 0; index < 100; index += 1) await Promise.resolve(); }; describe('WebBluetoothTransport', () => { + it.each(['connect', 'resolve'] as const)('does not restart %s when disconnected during initial cleanup', async (method) => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.remembered = [peripheral]; + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const connecting = method === 'connect' ? transport.connect('web-1') : transport.resolveAndConnect('desk-1'); + const rejected = expect(connecting).rejects.toThrow('cancelled'); + await transport.disconnect(); + await rejected; + expect(bluetooth.requests).toBe(0); + expect(peripheral.connects).toBe(0); + }); + + it('lets only the replacement attempt proceed after initial cleanup', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const first = transport.resolveAndConnect('old'); + const rejected = expect(first).rejects.toThrow('cancelled'); + await expect(transport.resolveAndConnect('desk-1')).resolves.toMatchObject({ desktopId: 'desk-1' }); + await rejected; + expect(bluetooth.requests).toBe(1); + expect(peripheral.connected).toBe(true); + await transport.disconnect(); + }); + + it('hands a selected probe to pairing without reconnecting or rediscovering its service', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + 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(); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index 5e2a1eb..1738804 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -96,6 +96,7 @@ export class WebBluetoothTransport implements BleTransport { #owners = new Map(); #attempts = 0; #probes = new Map(); + #discoveredProbe: { device: WebBluetoothDevice; server: WebBluetoothServer; service: WebBluetoothService; desktopId: string; attempt: number; retained: boolean } | null = null; #queue: Pending[] = []; #running: Pending | null = null; #queueGeneration = 0; @@ -143,13 +144,21 @@ export class WebBluetoothTransport implements BleTransport { active = false; if (operation === this.#operation) this.#operation += 1; const attempt = probe ? this.#probes.get(probe) : undefined; - if (probe && attempt !== undefined) this.#releaseProbe(probe, attempt); + if (probe && attempt !== undefined && !(this.#discoveredProbe?.device === probe && this.#discoveredProbe.retained)) this.#releaseProbe(probe, attempt); }; } + retainDiscoveredConnection(desktopId: string): boolean { + const probe = this.#discoveredProbe; + if (!probe || probe.desktopId !== desktopId || !probe.server.connected || this.#session || this.#owners.get(probe.device.id) !== probe.attempt) return false; + probe.retained = true; + return true; + } + async connect(peripheralId: string): Promise { - await this.disconnect(); const operation = ++this.#operation; + 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(); @@ -161,8 +170,9 @@ export class WebBluetoothTransport implements BleTransport { } async resolveAndConnect(desktopId: string): Promise { - await this.disconnect(); const operation = ++this.#operation; + await this.#disconnectExceptDiscovery(desktopId, operation); + if (operation !== this.#operation) throw cancelled(); this.#recordStage('resolution', 'started', operation); try { const known = this.#desktopDevices.get(desktopId); @@ -187,15 +197,23 @@ export class WebBluetoothTransport implements BleTransport { } async disconnect(): Promise { - this.#operation += 1; + 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]) this.#releaseProbe(probe, attempt); + for (const [probe, attempt] of [...this.#probes]) { + const ready = this.#discoveredProbe; + if (desktopId !== undefined && ready?.retained && ready.desktopId === desktopId && ready.device === probe && ready.server.connected) continue; + this.#releaseProbe(probe, attempt); + } const session = this.#session; this.#session = null; if (session) { @@ -307,17 +325,17 @@ export class WebBluetoothTransport implements BleTransport { async #open(device: WebBluetoothDevice, operation: number): Promise { this.#devices.set(device.id, device); + const probe = this.#discoveredProbe; + const reusable = probe?.device === device && probe.server.connected && this.#owners.get(device.id) === probe.attempt ? probe : null; + if (probe?.device === device) this.#discoveredProbe = null; this.#probes.delete(device); const attempt = ++this.#attempts; this.#owners.set(device.id, attempt); try { - const server = await this.#connectGatt(device, operation, attempt, 'connect'); - const characteristics = await this.#stage('services', () => this.#bounded(this.#characteristics(server), 'Bluetooth operation timed out.'), operation); - if (operation !== this.#operation) throw cancelled(); - const view = await this.#stage('status_read', () => this.#bounded(this.#gatt(() => characteristics.status.readValue()), 'Bluetooth operation timed out.'), operation); + 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 status = decodeStatus(view); - if (!status) throw new Error('Bluetooth discovery status is invalid.'); const readReplies = status.responseTransport === 'read-v1'; if (readReplies && !characteristics.response) throw new Error('Bluetooth discovery status is invalid.'); this.#desktopDevices.set(status.desktopId, device.id); @@ -366,15 +384,30 @@ export class WebBluetoothTransport implements BleTransport { } } - async #characteristics(server: WebBluetoothServer): Promise { - const service = await server.getPrimaryService(BLE_UUIDS.service); - const [receive, transmit, status, response] = await Promise.all([ - service.getCharacteristic(BLE_UUIDS.receive), - service.getCharacteristic(BLE_UUIDS.transmit), - service.getCharacteristic(BLE_UUIDS.status), - service.getCharacteristic(BLE_UUIDS.response).catch(() => null), - ]); - return { receive, transmit, status, response }; + async #characteristics(server: WebBluetoothServer, operation: number, 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. */ @@ -390,7 +423,9 @@ export class WebBluetoothTransport implements BleTransport { if (operation !== this.#operation) throw cancelled(); const characteristic = await this.#bounded(service.getCharacteristic(BLE_UUIDS.status), 'Bluetooth operation timed out.', remaining()); if (operation !== this.#operation) throw cancelled(); - const view = await this.#stage('status_read', () => this.#bounded(this.#gatt(() => characteristic.readValue()), 'Bluetooth operation timed out.', remaining()), operation); + // Probe operations are sequential on a disposable connection, not the session + // queue. A timed-out read must not block a subsequently chosen PC. + const view = await this.#stage('status_read', () => this.#bounded(characteristic.readValue(), 'Bluetooth operation timed out.', remaining()), operation); if (operation !== this.#operation) throw cancelled(); this.#recordStage('status_parse', 'started', operation); const status = decodeStatus(view); @@ -399,6 +434,8 @@ export class WebBluetoothTransport implements BleTransport { this.#releaseProbe(device, attempt); return null; } + if (operation !== this.#operation) throw cancelled(); + this.#discoveredProbe = { device, server, service, desktopId: status.desktopId, attempt, retained: false }; this.#devices.set(device.id, device); this.#desktopDevices.set(status.desktopId, device.id); return this.#describe(device, status); @@ -451,6 +488,7 @@ export class WebBluetoothTransport implements BleTransport { } #releaseProbe(device: WebBluetoothDevice, attempt: number): void { + if (this.#discoveredProbe?.device === device && this.#discoveredProbe.attempt === attempt) this.#discoveredProbe = null; if (this.#probes.get(device) === attempt) this.#probes.delete(device); this.#closeIfOwner(device, attempt); }