diff --git a/docs/accessibility.md b/docs/accessibility.md index 42325b0..94548fd 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -1,5 +1,10 @@ # Accessibility acceptance criteria +Web selector buttons must include their full current value in the accessible name. +Toggle controls expose pressed/unpressed state; ordinary action buttons must not be +announced as toggles. Busy and disabled controls expose those states to browser +assistive technology. Check the rendered DOM in addition to native prop tests. + On the web, live typing must submit from both keyboard Enter and the visible Enter control, then restore input focus after success or failure. Failure retains text and offers Retry Enter. Draft Enter remains multiline. Verify these using actual diff --git a/docs/web.md b/docs/web.md index e042946..ecb92a6 100644 --- a/docs/web.md +++ b/docs/web.md @@ -34,6 +34,15 @@ Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and Chr The protocol, framing limits, authentication and pairing approval are unchanged. See [protocol compatibility](protocol-compatibility.md). +First-time browser device identity creation uses an exclusive Web Lock, so tabs +share one persisted identity. Waiting for a lock is bounded to five seconds. If +locks are unavailable or persistence fails, initialization fails safely rather +than using an unsaved identity. Existing encrypted identities remain readable. +This keeps the existing keys, ciphertext format and native storage path unchanged. + +Connection cancellation includes initial cleanup: a stopped or replaced attempt +must not reopen the picker or begin another connection after cleanup completes. + ## Typing and probe recovery Keyboard Enter in live typing sends one Enter, clears only after acknowledgement, @@ -48,6 +57,22 @@ 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 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/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 92cc77d..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,141 @@ 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 { diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index ab5dfc5..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. */ @@ -401,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); @@ -453,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); }