diff --git a/docs/accessibility.md b/docs/accessibility.md index 42325b0..94548fd 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -1,5 +1,10 @@ # Accessibility acceptance criteria +Web selector buttons must include their full current value in the accessible name. +Toggle controls expose pressed/unpressed state; ordinary action buttons must not be +announced as toggles. Busy and disabled controls expose those states to browser +assistive technology. Check the rendered DOM in addition to native prop tests. + On the web, live typing must submit from both keyboard Enter and the visible Enter control, then restore input focus after success or failure. Failure retains text and offers Retry Enter. Draft Enter remains multiline. Verify these using actual diff --git a/docs/web.md b/docs/web.md index 393dc0f..ecb92a6 100644 --- a/docs/web.md +++ b/docs/web.md @@ -34,6 +34,15 @@ Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and Chr The protocol, framing limits, authentication and pairing approval are unchanged. See [protocol compatibility](protocol-compatibility.md). +First-time browser device identity creation uses an exclusive Web Lock, so tabs +share one persisted identity. Waiting for a lock is bounded to five seconds. If +locks are unavailable or persistence fails, initialization fails safely rather +than using an unsaved identity. Existing encrypted identities remain readable. +This keeps the existing keys, ciphertext format and native storage path unchanged. + +Connection cancellation includes initial cleanup: a stopped or replaced attempt +must not reopen the picker or begin another connection after cleanup completes. + ## Typing and probe recovery Keyboard Enter in live typing sends one Enter, clears only after acknowledgement, diff --git a/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/remote/RemoteDeviceSwitcher.tsx b/src/remote/RemoteDeviceSwitcher.tsx index 934f050..7f0e69e 100644 --- a/src/remote/RemoteDeviceSwitcher.tsx +++ b/src/remote/RemoteDeviceSwitcher.tsx @@ -7,6 +7,7 @@ import { AppText } from '@/components/AppText'; import { ControlButton } from '@/components/ControlButton'; import { EmptyState } from '@/components/EmptyState'; import { focusAccessibilityTarget } from '@/components/accessibilityFocus'; +import { webControlAccessibility } from '@/components/webControlAccessibility'; import type { ConnectionManager, ConnectionState } from '@/connection/ConnectionManager'; import type { SavedPc } from '@/storage/PairingStore'; import { useTheme } from '@/theme/ThemeContext'; @@ -94,6 +95,7 @@ export function RemoteDeviceSwitcher({ connection, manager, managePcs }: { conne accessibilityRole="button" accessibilityLabel="Switch PC" accessibilityValue={{ text: `${presentation.status}, ${presentation.name}` }} + {...webControlAccessibility(Platform.OS, { label: 'Switch PC', value: `${presentation.status}, ${presentation.name}`, expanded: visible })} accessibilityHint="Opens your saved PCs." onPress={open} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed ? colors.surfacePressed : colors.surfaceRaised, borderColor: colors.border, borderRadius: radii.lg, borderWidth: 1, flexDirection: 'row', gap: spacing.md, minHeight: 56, paddingHorizontal: spacing.md, paddingVertical: spacing.sm })} diff --git a/src/storage/PairingStore.test.ts b/src/storage/PairingStore.test.ts index e4df379..f396881 100644 --- a/src/storage/PairingStore.test.ts +++ b/src/storage/PairingStore.test.ts @@ -34,6 +34,17 @@ function fixture() { } describe('PairingStore transactions', () => { + it('uses platform atomic identity initialization when available', async () => { + const secrets = new SecretMemory(); + const atomic = jest.fn(async (_name: string, _create: () => string) => 'shared-device'); + const storage = Object.assign(secrets, { getOrCreateItemAsync: atomic }); + const first = new PairingStore(new PublicMemory(), storage); + const second = new PairingStore(new PublicMemory(), storage); + expect(await Promise.all([first.getDeviceId(), second.getDeviceId()])).toEqual(['shared-device', 'shared-device']); + expect(atomic).toHaveBeenCalledWith('switchify.remote.device-id.v1', expect.any(Function)); + expect(secrets.values.size).toBe(0); + }); + it('rolls back a token update when saving the public index fails', async () => { const { store, publicStorage } = fixture(); publicStorage.failSet = 1; diff --git a/src/storage/PairingStore.ts b/src/storage/PairingStore.ts index 888ebda..19d8597 100644 --- a/src/storage/PairingStore.ts +++ b/src/storage/PairingStore.ts @@ -36,6 +36,9 @@ export class PairingStore implements PairingStorage { constructor(private readonly publicStorage: PublicStorage = AsyncStorage, private readonly secretStorage: SecretStorage = platformSecretStorage) {} async getDeviceId(): Promise { + if (this.secretStorage.getOrCreateItemAsync) { + return this.secretStorage.getOrCreateItemAsync(DEVICE_ID_KEY, () => `remote-${Crypto.randomUUID()}`); + } const existing = await this.secretStorage.getItemAsync(DEVICE_ID_KEY); if (existing) return existing; const created = `remote-${Crypto.randomUUID()}`; diff --git a/src/storage/secretStorage.ts b/src/storage/secretStorage.ts index 08ee111..f491fac 100644 --- a/src/storage/secretStorage.ts +++ b/src/storage/secretStorage.ts @@ -1,5 +1,7 @@ import * as SecureStore from 'expo-secure-store'; -export type SecretStorage = Pick; +export type SecretStorage = Pick & { + getOrCreateItemAsync?: (name: string, create: () => string) => Promise; +}; export const secretStorage: SecretStorage = SecureStore; diff --git a/src/storage/secretStorage.web.test.ts b/src/storage/secretStorage.web.test.ts index a17f255..7ef61bc 100644 --- a/src/storage/secretStorage.web.test.ts +++ b/src/storage/secretStorage.web.test.ts @@ -70,9 +70,73 @@ beforeEach(() => { Object.defineProperty(window, 'indexedDB', { configurable: true, value: indexedDb }); Object.defineProperty(window, 'crypto', { configurable: true, value: webcrypto }); Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true }); + let queue: Promise = Promise.resolve(); + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { + request: (_name: string, _options: unknown, callback: () => Promise) => { + const next = queue.catch(() => undefined).then(callback); + queue = next; + return next; + }, + } }); }); describe('web secret storage', () => { + it('creates one persistent identity across independent browser instances', async () => { + const first = load().secretStorage; + const second = load().secretStorage; + const createFirst = jest.fn(() => 'remote-one'); + const createSecond = jest.fn(() => 'remote-two'); + const identities = await Promise.all([ + first.getOrCreateItemAsync!('device-id', createFirst), + second.getOrCreateItemAsync!('device-id', createSecond), + ]); + expect(identities[0]).toBe(identities[1]); + expect(createFirst.mock.calls.length + createSecond.mock.calls.length).toBe(1); + expect(await load().secretStorage.getItemAsync('device-id')).toBe(identities[0]); + expect(Object.values({ ...window.localStorage }).join(' ')).not.toContain(identities[0]); + }); + + it('preserves an existing identity without requiring a lock', async () => { + const storage = load().secretStorage; + await storage.setItemAsync('device-id', 'existing'); + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: undefined }); + const create = jest.fn(() => 'replacement'); + expect(await storage.getOrCreateItemAsync!('device-id', create)).toBe('existing'); + expect(create).not.toHaveBeenCalled(); + }); + + it('fails safely without cross-tab locks rather than racing a new identity', async () => { + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: undefined }); + const storage = load().secretStorage; + const create = jest.fn(() => 'unsafe'); + await expect(storage.getOrCreateItemAsync!('device-id', create)).rejects.toThrow('unavailable'); + expect(create).not.toHaveBeenCalled(); + expect(await storage.getItemAsync('device-id')).toBeNull(); + }); + + it('bounds waiting for a lock held by another tab', async () => { + jest.useFakeTimers(); + try { + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { + request: (_name: string, options: { signal: AbortSignal }) => new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new Error('lock aborted'))); + }), + } }); + const create = jest.fn(() => 'late'); + const pending = load().secretStorage.getOrCreateItemAsync!('device-id', create); + const rejected = expect(pending).rejects.toThrow('unavailable'); + await jest.advanceTimersByTimeAsync(5_000); + await rejected; + expect(create).not.toHaveBeenCalled(); + } finally { jest.useRealTimers(); } + }); + + it('does not return an identity when persistence fails', async () => { + indexedDb.abortCommit = true; + await expect(load().secretStorage.getOrCreateItemAsync!('device-id', () => 'unsaved')).rejects.toThrow('unavailable'); + expect(window.localStorage.length).toBe(0); + }); + it('round-trips a secret without storing it in plain text', async () => { const { secretStorage } = load(); await secretStorage.setItemAsync('switchify.remote.token.pc-1', 'secret-token'); diff --git a/src/storage/secretStorage.web.ts b/src/storage/secretStorage.web.ts index 827bc42..f08a5ee 100644 --- a/src/storage/secretStorage.web.ts +++ b/src/storage/secretStorage.web.ts @@ -1,6 +1,8 @@ import type * as SecureStore from 'expo-secure-store'; -export type SecretStorage = Pick; +export type SecretStorage = Pick & { + getOrCreateItemAsync?: (name: string, create: () => string) => Promise; +}; const DATABASE = 'switchify-remote-secrets'; const STORE = 'keys'; @@ -133,4 +135,28 @@ async function deleteItemAsync(name: string): Promise { window.localStorage.removeItem(`${VALUE_PREFIX}${name}`); } -export const secretStorage = { getItemAsync, setItemAsync, deleteItemAsync } as SecretStorage; +async function getOrCreateItemAsync(name: string, create: () => string): Promise { + const existing = await getItemAsync(name); + if (existing) return existing; + // The identity read, generation and encrypted write must share a cross-tab lock. + // A per-tab promise only protects one instance and can invalidate another pairing. + if (!window.navigator.locks) throw unavailable(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5_000); + try { + return await window.navigator.locks.request(`switchify.remote.initialize.${name}`, { mode: 'exclusive', signal: controller.signal }, async () => { + clearTimeout(timer); + const raced = await getItemAsync(name); + if (raced) return raced; + const created = create(); + await setItemAsync(name, created); + return created; + }); + } catch { + throw unavailable(); + } finally { + clearTimeout(timer); + } +} + +export const secretStorage: SecretStorage = { getItemAsync, setItemAsync, deleteItemAsync, getOrCreateItemAsync }; diff --git a/src/transport/WebBluetoothTransport.test.ts b/src/transport/WebBluetoothTransport.test.ts index ef2df6a..3ce0310 100644 --- a/src/transport/WebBluetoothTransport.test.ts +++ b/src/transport/WebBluetoothTransport.test.ts @@ -106,6 +106,34 @@ class FakeBluetooth implements WebBluetooth { const flush = async () => { for (let index = 0; index < 100; index += 1) await Promise.resolve(); }; describe('WebBluetoothTransport', () => { + it.each(['connect', 'resolve'] as const)('does not restart %s when disconnected during initial cleanup', async (method) => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.remembered = [peripheral]; + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const connecting = method === 'connect' ? transport.connect('web-1') : transport.resolveAndConnect('desk-1'); + const rejected = expect(connecting).rejects.toThrow('cancelled'); + await transport.disconnect(); + await rejected; + expect(bluetooth.requests).toBe(0); + expect(peripheral.connects).toBe(0); + }); + + it('lets only the replacement attempt proceed after initial cleanup', async () => { + const bluetooth = new FakeBluetooth(); + const peripheral = new FakePeripheral('web-1', status('desk-1')); + bluetooth.chooser = async () => peripheral; + const transport = new WebBluetoothTransport(() => bluetooth); + const first = transport.resolveAndConnect('old'); + const rejected = expect(first).rejects.toThrow('cancelled'); + await expect(transport.resolveAndConnect('desk-1')).resolves.toMatchObject({ desktopId: 'desk-1' }); + await rejected; + expect(bluetooth.requests).toBe(1); + expect(peripheral.connected).toBe(true); + await transport.disconnect(); + }); + it('hands a selected probe to pairing without reconnecting or rediscovering its service', async () => { const bluetooth = new FakeBluetooth(); const peripheral = new FakePeripheral('web-1', status('desk-1')); diff --git a/src/transport/WebBluetoothTransport.ts b/src/transport/WebBluetoothTransport.ts index 96fdeef..1738804 100644 --- a/src/transport/WebBluetoothTransport.ts +++ b/src/transport/WebBluetoothTransport.ts @@ -156,8 +156,9 @@ export class WebBluetoothTransport implements BleTransport { } async connect(peripheralId: string): Promise { - await this.disconnect(); const operation = ++this.#operation; + await this.#disconnectExceptDiscovery(undefined, operation); + if (operation !== this.#operation) throw cancelled(); let device = this.#devices.get(peripheralId) ?? null; if (!device) { const remembered = await this.#rememberedDevices(); @@ -169,8 +170,9 @@ export class WebBluetoothTransport implements BleTransport { } async resolveAndConnect(desktopId: string): Promise { - await this.#disconnectExceptDiscovery(desktopId); const operation = ++this.#operation; + await this.#disconnectExceptDiscovery(desktopId, operation); + if (operation !== this.#operation) throw cancelled(); this.#recordStage('resolution', 'started', operation); try { const known = this.#desktopDevices.get(desktopId); @@ -198,13 +200,13 @@ export class WebBluetoothTransport implements BleTransport { await this.#disconnectExceptDiscovery(); } - async #disconnectExceptDiscovery(desktopId?: string): Promise { - this.#operation += 1; + async #disconnectExceptDiscovery(desktopId?: string, operation = ++this.#operation): Promise { this.#responsePoller?.stop(); this.#responsePoller = null; this.#notificationsReady = null; this.#cancelTimers(); await this.cancelPendingWrites(); + if (operation !== this.#operation) return; this.#resetQueue(); this.#writePoisoned = false; for (const [probe, attempt] of [...this.#probes]) {