diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index a15c291..7b957a6 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -27,3 +27,10 @@ Authenticated `connection.ping` commands may include an optional `deviceName`. C ## Shared remote actions The catalog contains only stable IDs, descriptive searchable metadata, placement rules, and declarative action definitions. The runtime resolver attaches current platform labels, selected/disabled states, explanations, and callbacks from the active RemoteSession. Both original and customized grids use it. Monitor behavior is defined once; keys in live Typing use its stream and Enter controller, while keys elsewhere use normal PC key commands. Draft actions require draft mode and applicable text/capabilities. Picker selection handles IDs only. Layout storage contains no handlers, search text, typing content, or command payloads. Reconnection refreshes runtime state without rewriting layouts. +# Linux read-response transport + +An optional discovery field `responseTransport: "read-v1"` selects the read-only response characteristic `7a78f7ec-1d6d-4d92-9ef0-1f89d3db21f4` under the existing service. Absent fields retain the existing notification path; unknown values reject discovery/connection. Existing UUIDs, protocol-v1 frames, authentication and stored pairing schemas are unchanged. A Linux server using this mode never sends sensitive responses through notifications; older Remotes must update to pair with it. + +One serial ATT read consumes one JSON/base64 frame (maximum 180 bytes), or returns an empty value when idle. ATT long reads must preserve a server snapshot for nonzero offsets. Poll immediately after data, or after 100 ms when idle. Existing protocol parsing/reassembly and request deadlines remain authoritative. Read failures terminate the receive path with a fixed error and poison writes until reconnect: consumed frames are never retried. Unsubscribe/disconnect cancels the native transaction, timers and late deliveries. Initial successful read replaces notification-descriptor readiness; no notification listener is registered in this mode. + +Fake tests cover negotiation, backward compatibility, response bounds, serial polling, timeout and cancellation. Physical Android interoperability is not yet qualified. diff --git a/package-lock.json b/package-lock.json index d30ab9d..f282612 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,33 +8,33 @@ "name": "switchify-remote", "version": "1.0.0-beta.23", "dependencies": { - "@expo/ui": "~57.0.17", + "@expo/ui": "~57.0.18", "@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.21", - "expo-blur": "~57.0.2", + "expo": "~57.0.22", + "expo-blur": "~57.0.3", "expo-build-properties": "~57.0.17", - "expo-clipboard": "~57.0.1", - "expo-constants": "~57.0.16", - "expo-crypto": "~57.0.2", - "expo-dev-client": "~57.0.18", - "expo-device": "~57.0.1", - "expo-file-system": "~57.0.6", - "expo-font": "~57.0.2", - "expo-glass-effect": "~57.0.2", - "expo-image": "~57.0.4", - "expo-linking": "~57.0.9", - "expo-router": "~57.0.20", - "expo-secure-store": "~57.0.3", - "expo-sharing": "~57.0.18", - "expo-splash-screen": "~57.0.8", + "expo-clipboard": "~57.0.2", + "expo-constants": "~57.0.18", + "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-image": "~57.0.5", + "expo-linking": "~57.0.10", + "expo-router": "~57.0.21", + "expo-secure-store": "~57.0.4", + "expo-sharing": "~57.0.19", + "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", - "expo-symbols": "~57.0.2", - "expo-system-ui": "~57.0.3", - "expo-web-browser": "~57.0.2", + "expo-symbols": "~57.0.3", + "expo-system-ui": "~57.0.4", + "expo-web-browser": "~57.0.3", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", @@ -1650,9 +1650,9 @@ } }, "node_modules/@expo-google-fonts/material-symbols": { - "version": "0.4.44", - "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.44.tgz", - "integrity": "sha512-36JP9Chcy/QEVZ9ZGY4i6zInlyFPbQjkIm6gRNuWWltScIj0WR8rddcD57EIjSC5YKCe2ZKLO6eO/N5r8Jit0A==", + "version": "0.4.47", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.47.tgz", + "integrity": "sha512-5G4ObZlyGvDzx6g/+NC+RAMeeCc3dc0/HLu/llY5dYZUlNDYzaZWQZwPUzoTixf27riT8I7ur9mQKPYCPFDJzQ==", "license": "MIT AND Apache-2.0" }, "node_modules/@expo/code-signing-certificates": { @@ -1781,9 +1781,9 @@ "license": "MIT" }, "node_modules/@expo/fingerprint": { - "version": "0.20.12", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.12.tgz", - "integrity": "sha512-FIR5fkZYeFaLSowmjgyB6RPKl8AXeE8HuCBHHvyxK8UhOjpPfCAmzT4E7v2yub4qqDafKnfcOFCYxvpUEu+01w==", + "version": "0.20.13", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.13.tgz", + "integrity": "sha512-h1x+w+JpNRu88a/5SMZRzJs9YDGR/f7R8ApCr7OdenhqmJ00hFxQMzvxajDLL+1P2Y2g/ND0xksusqvBqWSW5Q==", "license": "MIT", "dependencies": { "@expo/env": "^2.4.3", @@ -2295,9 +2295,9 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "57.0.15", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.15.tgz", - "integrity": "sha512-xTbWHroj0PDmlbqvmU+zF9ZZxveJkiuyiPoeRJYRGruFHebRAWnoTdw5S7d/UCzDBI8ropGGu9g2eb2nMxtvAw==", + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.16.tgz", + "integrity": "sha512-QA8oMZ7y70kjOzw+abhRl9uYC8Nu/abeUTHr0YVxb3pgIC14gc7w543bYtI+5gEDI3q55jKUViIauC76QvZN1w==", "license": "MIT", "dependencies": { "@expo/config": "~57.0.9", @@ -2307,7 +2307,7 @@ "@expo/json-file": "^11.0.1", "@react-native/normalize-colors": "0.86.3", "debug": "^4.3.1", - "expo-modules-autolinking": "~57.0.12", + "expo-modules-autolinking": "~57.0.13", "resolve-from": "^5.0.0", "semver": "^7.6.0" } @@ -2362,9 +2362,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "57.0.17", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.17.tgz", - "integrity": "sha512-cnenUTsfX78i/EfXhA81Zv48sOxRdIq4YWCggwgBiPoOyS8J5CP/6dGyKo26ZVtcIsogWR4aXywaripeRF+6Ng==", + "version": "57.0.18", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.18.tgz", + "integrity": "sha512-9RkA26v0FSyHlkQCyavZrT8EzsALfkNHIFO5uC+IsAxzWzItdOWdy6asjpg1yaCvBEGMYkP17FUZxluhVbeleA==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -5903,14 +5903,15 @@ } }, "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", + "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", @@ -5918,6 +5919,10 @@ }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/compression/node_modules/debug": { @@ -7535,31 +7540,31 @@ } }, "node_modules/expo": { - "version": "57.0.21", - "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.21.tgz", - "integrity": "sha512-lQmC0kCJCleO+uLUwHXY0pLDzcvedKEsX+pmJp4mSxn3JlWDTZvUb0e5UIlV7+bqlN68LpAeKG4c2lrN4zuP0Q==", + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.22.tgz", + "integrity": "sha512-YWUGqWtchsRQzcbU+Iqqt/1kFbZ9Pp3/eg+UenINahNt2RIaDElFJFFWLD1Yfex6WnBm6ESSE8h0/ja28hRqZg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^57.0.23", + "@expo/cli": "^57.0.24", "@expo/config": "~57.0.9", "@expo/config-plugins": "~57.0.9", "@expo/devtools": "~57.0.1", "@expo/dom-webview": "~57.0.1", - "@expo/fingerprint": "^0.20.12", + "@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", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.11", - "expo-asset": "~57.0.16", - "expo-constants": "~57.0.17", - "expo-file-system": "~57.0.6", - "expo-font": "~57.0.3", - "expo-keep-awake": "~57.0.1", - "expo-modules-autolinking": "~57.0.12", - "expo-modules-core": "~57.0.17", + "expo-asset": "~57.0.17", + "expo-constants": "~57.0.18", + "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", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -7597,13 +7602,13 @@ } }, "node_modules/expo-asset": { - "version": "57.0.16", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.16.tgz", - "integrity": "sha512-IBRfQdW3iFT+GOBERMZLZM1MUNyrjMgMskuD0elVZ1ae44858UFlTJkHO3f8vi+u5zuv7O7KofsiN8NMG/uWzw==", + "version": "57.0.17", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.17.tgz", + "integrity": "sha512-qSdWZBSfEg06o3GJMwcj36tw0vXkB6eYjqkQistWmyJkHxhUOKnsKJnev6OraXxLdoI+KOd1+qgp/ENx8gcqYA==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.11.5", - "expo-constants": "~57.0.17" + "expo-constants": "~57.0.18" }, "peerDependencies": { "expo": "*", @@ -7612,9 +7617,9 @@ } }, "node_modules/expo-blur": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.2.tgz", - "integrity": "sha512-Aoud8H8lmlNkbRufyvRLefmGFELdBf1n5Te/Xm+Zx8ORINH+aXL+gKb5mbftFSha860+I7pMArz77TBYz8HDVg==", + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.3.tgz", + "integrity": "sha512-uyBeJwxIuXzWXXNrnRub5pcF8VzQsiOz0VBTWDpMG2UztmaFyfAz6pI/GCwRtWpHhiOpLoJj79WLdg1GMB+9cg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -7637,9 +7642,9 @@ } }, "node_modules/expo-clipboard": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.1.tgz", - "integrity": "sha512-HWICri4+1ao7S6QEfcorxVumXDiDnx1guGGewjZgGJWLGxFYs0RgH8ujBs+lkTzBkMmlwADaWSlaesR+nDJt5Q==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.2.tgz", + "integrity": "sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -7648,9 +7653,9 @@ } }, "node_modules/expo-constants": { - "version": "57.0.17", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.17.tgz", - "integrity": "sha512-cPWYBKN1SEbg2lXg2f8VkePJqGZrJPLvVdQDCTfbFu9sQHO1M31Y1zILReUuGnmt/VKeUz40ze579vR00xGu/A==", + "version": "57.0.18", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.18.tgz", + "integrity": "sha512-cLDwDOniPfuCZPHNY++cAY2ZkK97u5aobbBvilPK2LLmaWxR4ZpD31LWUIzUDLrwMPxLjmh1uURnbeYEf0Mivw==", "license": "MIT", "dependencies": { "@expo/env": "~2.4.3" @@ -7661,39 +7666,39 @@ } }, "node_modules/expo-crypto": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.2.tgz", - "integrity": "sha512-OGLvHK7Hb7qsONMWGB9pJw3n+iY4OnZQmM/KwQEHRMrrOmNg0Lxj9+tp9bj46aAMMzXx0FTVA2jUKoplpXPnkA==", + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.3.tgz", + "integrity": "sha512-SAWqEfF37nc8ot7bXsVmu9AFYwQdC2kGvH/eNAHiR3bmlcf1Vv8wu9AnGKT+a90r3T8YfwMpZP4QAYlo4vCn9w==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-dev-client": { - "version": "57.0.18", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.18.tgz", - "integrity": "sha512-cT+6iO4rRoguIxeONAPHF2wbAlc002tZya2M3xHlc6EWKptjsAL2Vr+kagYdLH/4vtLX7XKhWvOzaBf+rQpRFg==", + "version": "57.0.19", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.19.tgz", + "integrity": "sha512-7giMK6BLL685GECHem2UAQvS473ImZLi5l+Q5tq+XSyv6I9UNVPs0p07mKwgBzq8z7DR3d4nTl702ziFxTjO3Q==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "~57.0.19", + "expo-dev-launcher": "~57.0.20", "expo-dev-menu": "~57.0.18", "expo-dev-menu-interface": "~57.0.0", - "expo-manifests": "~57.0.1", - "expo-updates-interface": "~57.0.1" + "expo-manifests": "~57.0.2", + "expo-updates-interface": "~57.0.2" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-dev-launcher": { - "version": "57.0.19", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-57.0.19.tgz", - "integrity": "sha512-IF/EJ3tcHqiyfBb5VtpAU3upBXRVTFVuVoWqEzrkZCesuMi35td4yQFvPtqXbMceaEErgFyiyxhW+L6AXVYapA==", + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-57.0.20.tgz", + "integrity": "sha512-F6oAHaDn9j3L/gyMGE+OalriX6PHZWnZLxpN5jc6crTrp8f6hSwGhmsHpAvdOzijfMjyi3R2UkcISFsCIIQYFA==", "license": "MIT", "dependencies": { "@expo/schema-utils": "^57.0.2", "expo-dev-menu": "~57.0.18", - "expo-manifests": "~57.0.1" + "expo-manifests": "~57.0.2" }, "peerDependencies": { "expo": "*", @@ -7723,9 +7728,9 @@ } }, "node_modules/expo-device": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-57.0.1.tgz", - "integrity": "sha512-jyEMDUticH+dhcL3GHa2aiifOvGXJsmb3oVT2R2q4i8bN7Bddy61+NkpMmuS2VAZrvoLQwf0TJJ/1vi1ukvutA==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-57.0.2.tgz", + "integrity": "sha512-1A0/FICR3CgrH43FeiDQP3l5eHDmiMoh4EjXfhfoHHkMgdBQdyD0kwDWskiGZlQ3E0Nd0LHkCFH6vJ1ofm/jow==", "license": "MIT", "dependencies": { "ua-parser-js": "^0.7.33" @@ -7735,9 +7740,9 @@ } }, "node_modules/expo-file-system": { - "version": "57.0.6", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.6.tgz", - "integrity": "sha512-pm8PMYEW6BnVOCBJ7df9FcDmQtE1tqImuYphlfYe1ipQRLYtdCayRczJcbKIsnB3mqEyp4gC2gWmMbsAsAOjVg==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", + "integrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -7745,9 +7750,9 @@ } }, "node_modules/expo-font": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.3.tgz", - "integrity": "sha512-kiVUnc2A8vAvO2FfDJTsQa5BwmY+PAkof/1wRb5MOkcX1jtiaSTwz9gCUAyscMBFLundZDmZrLy1P7LZVC+NvA==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", + "integrity": "sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA==", "license": "MIT", "dependencies": { "fontfaceobserver": "^2.1.0" @@ -7759,9 +7764,9 @@ } }, "node_modules/expo-glass-effect": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-57.0.2.tgz", - "integrity": "sha512-k2Dk0uCJrGYe8DWfvlMPmJ6w7uxCmJpoJckIcNDoUn27g75c5Kv3nUa88+T4+CpeVkixwY7iWrv46B5De20S9Q==", + "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==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -7770,9 +7775,9 @@ } }, "node_modules/expo-image": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.4.tgz", - "integrity": "sha512-DLVNjqIMLb71wh0F27kRv7nA9i4g80fvkVugiimVgGnO7Iot+gRSv5VhrFJCvX/OlSzbGQqd/3ktlEaPGFqP7g==", + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.5.tgz", + "integrity": "sha512-jOm5lofaIKXhQ7i0Hz3I5JuNULw/aghmpIPZXddwQcEtw8+wUhlozq3M7LbsOrcsBEapcG4jFs85DsfuFoz1tQ==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.2.0" @@ -7790,15 +7795,15 @@ } }, "node_modules/expo-json-utils": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.1.tgz", - "integrity": "sha512-cgTe1NqzQdYs/WN+3nIY5IZg8s0pb0xaTUbhYvxQDn137GbwRfHoGM2se3m3Vsl4Qu+B9G4RPEK5WJDEU2Do7g==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.2.tgz", + "integrity": "sha512-9kYZECLKE3pYZQs+kt3+ggY83Lfryog9/UEo3HJKGcHUZeKKtlFBIPFjI4LzREubsvjP7AsvG0FdEX9EquIoAQ==", "license": "MIT" }, "node_modules/expo-keep-awake": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.1.tgz", - "integrity": "sha512-28lkFImeXTS+bhAjuCFV7w7tW5bXg27BJVrxv+nC/nyYa86qEa0oFeHwqol6ha5k4pdVDQgBF09GM4A1k76Ssg==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", + "integrity": "sha512-GqgH746wtJImmsbyHmCQoi7cOslKuWQBbJHXr53S35Bpf5UvxCAztvnitUEd7D0QvFN2rvkOhylbZpgZZfBLlQ==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -7806,12 +7811,12 @@ } }, "node_modules/expo-linking": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.9.tgz", - "integrity": "sha512-TQJe3NsnpZXApBg1n6DKaZ4TNhYm+iYWq7W9rTp+G44IBMZSUcI8ba+ffYC/LMY1qw+95AuBPIF1zTx1NQoTXw==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.10.tgz", + "integrity": "sha512-RzRi4l4pIYzjgoCJKnqkO+lUI+M2YX6qgcMIQkIbLAXdcWrykBoKYwHGlmAkdFHQm89GaYcq329yfxG+c1hv8w==", "license": "MIT", "dependencies": { - "expo-constants": "~57.0.17", + "expo-constants": "~57.0.18", "invariant": "^2.2.4" }, "peerDependencies": { @@ -7820,21 +7825,21 @@ } }, "node_modules/expo-manifests": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.1.tgz", - "integrity": "sha512-qB/mDG2dYdl+EvUeQuqP8KFYCFgFCQjJYdWIHo8SFBgDzMYmdF286DFY2M1M9Okr99wkb5M4tgA3aCcwv3aEQA==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.2.tgz", + "integrity": "sha512-pPIIcKR73OShwlWMg8H90WRzKUd2krDdKlKAwuQV1FtUwE7H3hQEFw0ePFQY9yFoGMF/77ryYwNKfI5umMcCog==", "license": "MIT", "dependencies": { - "expo-json-utils": "~57.0.1" + "expo-json-utils": "~57.0.2" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-modules-autolinking": { - "version": "57.0.12", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.12.tgz", - "integrity": "sha512-Q8KAlq37nLKsQ+HsS9NpQVpd5jCgqtu694TDUNHBUBpV9ViD82mRBh8Uug/h68RG9xnLS+kuL4nYaCuFRghHjg==", + "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==", "license": "MIT", "dependencies": { "@expo/require-utils": "^57.0.5", @@ -7847,9 +7852,9 @@ } }, "node_modules/expo-modules-core": { - "version": "57.0.17", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.17.tgz", - "integrity": "sha512-hHJwGHW0sMQiOLzEEl1QbJeWkBEBgjlPjrdBVGWlzvgUbCXJfFbDU2nhxoyzLMZnvHkpIzpiJjj5cM5W8gbudA==", + "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==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", @@ -7877,15 +7882,15 @@ } }, "node_modules/expo-router": { - "version": "57.0.20", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.20.tgz", - "integrity": "sha512-pBY0Ek5opUgpUSdoX3bAmyd8mkGSeTIOEdIDdpSG/02owrrwpnaQuSn/XuwOtizoeFedAD3VgS5DDVSyQTdI7g==", + "version": "57.0.21", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.21.tgz", + "integrity": "sha512-r7UDLZU0gtVm8FW4nv2pxSl9TDhKlf2shcBbfp1T9STXJ3xYl9llLH701qQkICuXqqt3vGpOZ57GXC1ioVWg4g==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.4", "@expo/metro-runtime": "^57.0.15", "@expo/schema-utils": "^57.0.2", - "@expo/ui": "^57.0.17", + "@expo/ui": "^57.0.18", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -7893,12 +7898,12 @@ "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", - "expo-glass-effect": "^57.0.2", + "expo-glass-effect": "^57.0.3", "expo-server": "^57.0.3", - "expo-symbols": "^57.0.2", + "expo-symbols": "^57.0.3", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", - "nanoid": "^3.3.8", + "nanoid": "^3.3.18", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", @@ -7915,8 +7920,8 @@ "@expo/metro-runtime": "^57.0.15", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^57.0.17", - "expo-linking": "^57.0.9", + "expo-constants": "^57.0.18", + "expo-linking": "^57.0.10", "react": "*", "react-dom": "*", "react-native": "*", @@ -8038,9 +8043,9 @@ } }, "node_modules/expo-secure-store": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.3.tgz", - "integrity": "sha512-w7XkSQeUiYXPoKXo1jSrQqql7pyCSyIzOp2k0apsZZBE+RkoLTQIMjcbqc181y6KgupfNnYxkaKe+4MEg94+SA==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.4.tgz", + "integrity": "sha512-7zBA1RThURTXdP4w65kBzZNbcSsfArqVd+p+ETAnPRWXO8J296Qd0YaCVyRpJ0HSwlCnGdvOnqO+NgSreHsOhA==", "license": "MIT", "peerDependencies": { "expo": "*" @@ -8056,9 +8061,9 @@ } }, "node_modules/expo-sharing": { - "version": "57.0.18", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.18.tgz", - "integrity": "sha512-Q09Gab1yn+OLR7sqcHBNyjM/4MMQa290hDKCV2pf2THnTLkLLuUmC0AKgMgxygqU0uuzhOgVqfnE70dePCLq9A==", + "version": "57.0.19", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.19.tgz", + "integrity": "sha512-lZ9chd/kxalZkMaJnPxW6dEpj7hnlrikesiEV9KLS99VKgkfLgH+/pFvIfk0YwGJ1b1/eLGcZ7OfJ2jjSgFpNA==", "license": "MIT", "dependencies": { "@expo/config-plugins": "^57.0.9", @@ -8072,9 +8077,9 @@ } }, "node_modules/expo-splash-screen": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.8.tgz", - "integrity": "sha512-BEsrKg4niYBZa5AFzWRyGqxA4ZemtBOPwbCy+C336iGYYPuZYLEgWJGmm2xD1gRfCdrM/TP+l2ONd86Zvk3KyA==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.9.tgz", + "integrity": "sha512-7cKDn7uBwg0RkcmY015/5j9st4msWJ4zQiPmQdVZoppW3cFSbv05n7xhM6CjUU466Tx4VRTqDF21HuIHkmnEtA==", "license": "MIT", "dependencies": { "@expo/config-plugins": "~57.0.9", @@ -8097,9 +8102,9 @@ } }, "node_modules/expo-symbols": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.2.tgz", - "integrity": "sha512-qZ0iqOflm5lZGwRsQ5Y8sDksw3GAUKwHSX1bJBoocXf7gu14vafIXXYWte+JT9VfXAUGyKodJhLHP/GoOrcNWg==", + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.3.tgz", + "integrity": "sha512-uuK5yHLVR7kExUCrO/ItBvp/kp1maQ9Nkb6KvNy5ZUQnN0NMKwuhJ4GvbfS9N3ACmvCjTT4gKvg4yH5UuKIYNA==", "license": "MIT", "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", @@ -8113,9 +8118,9 @@ } }, "node_modules/expo-system-ui": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.3.tgz", - "integrity": "sha512-3N3BoagGojEvQw0h7kL0Lvv2LrVjqEvODSNS+Nc9lj/kzaTaVx4K1HI8qqxeALMf5jui3rLP2b3Ai2pXotoNuA==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.4.tgz", + "integrity": "sha512-z//rxQJzhTNA1PcKrUtWmrd21jAS3nEDu1b0n8B/7LTTR933OcsZYP1HnbaZifuo2ZH/h7nce2Ow61WZ1Ig2bg==", "license": "MIT", "dependencies": { "@react-native/normalize-colors": "0.86.3", @@ -8133,18 +8138,18 @@ } }, "node_modules/expo-updates-interface": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz", - "integrity": "sha512-+LUWwJ0gf/TEKMVdQAw/Gjih4dvrk+URgy24X9qEGKuuMDZqjBRm9T4yQyBVALGL5TTdPUaB6ILxx3lshm3pwQ==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.2.tgz", + "integrity": "sha512-x3SA5toiHZEkW+5oeNdUM1Rvt6M3ExegQsSwS3Z1+S9gn9utGYTGBkb8MBQivhjIZiRlQYDIhauxA8kySQEWtQ==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-web-browser": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.2.tgz", - "integrity": "sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==", + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.3.tgz", + "integrity": "sha512-+ecvhyS/PdWkpv4ai6FF1Trd6Y/4kn/eQ3O86mie2ecU3w+I8bKZh13kppY/jw1uSL7yikt03L5W/iiHojrFwQ==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -8152,9 +8157,9 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "57.0.23", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.23.tgz", - "integrity": "sha512-stzSYxVwbWGbKR+mrYT6s4Rt6HrcUxb0JrHyebDGL4ehpvfRTv0rMzn4Q7fqB6f5wJ1KCmf6OAgqgK+TTB8/FA==", + "version": "57.0.24", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.24.tgz", + "integrity": "sha512-CCBkvhPgAcrzJQ3daYbyOD6YuqKKkvOIyUfAbsUIvlpLomIlocYxFHYdmaC/KHqfCsbGFYSV/QKTSYshhSFNEA==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", @@ -8172,7 +8177,7 @@ "@expo/osascript": "^2.7.1", "@expo/package-manager": "^1.13.1", "@expo/plist": "^0.8.1", - "@expo/prebuild-config": "^57.0.15", + "@expo/prebuild-config": "^57.0.16", "@expo/require-utils": "^57.0.5", "@expo/router-server": "^57.0.9", "@expo/schema-utils": "^57.0.2", diff --git a/package.json b/package.json index 0be2750..9ae1d25 100644 --- a/package.json +++ b/package.json @@ -3,33 +3,33 @@ "main": "expo-router/entry", "version": "1.0.0-beta.23", "dependencies": { - "@expo/ui": "~57.0.17", + "@expo/ui": "~57.0.18", "@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.21", - "expo-blur": "~57.0.2", + "expo": "~57.0.22", + "expo-blur": "~57.0.3", "expo-build-properties": "~57.0.17", - "expo-clipboard": "~57.0.1", - "expo-constants": "~57.0.16", - "expo-crypto": "~57.0.2", - "expo-dev-client": "~57.0.18", - "expo-device": "~57.0.1", - "expo-file-system": "~57.0.6", - "expo-font": "~57.0.2", - "expo-glass-effect": "~57.0.2", - "expo-image": "~57.0.4", - "expo-linking": "~57.0.9", - "expo-router": "~57.0.20", - "expo-secure-store": "~57.0.3", - "expo-sharing": "~57.0.18", - "expo-splash-screen": "~57.0.8", + "expo-clipboard": "~57.0.2", + "expo-constants": "~57.0.18", + "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-image": "~57.0.5", + "expo-linking": "~57.0.10", + "expo-router": "~57.0.21", + "expo-secure-store": "~57.0.4", + "expo-sharing": "~57.0.19", + "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", - "expo-symbols": "~57.0.2", - "expo-system-ui": "~57.0.3", - "expo-web-browser": "~57.0.2", + "expo-symbols": "~57.0.3", + "expo-system-ui": "~57.0.4", + "expo-web-browser": "~57.0.3", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", diff --git a/src/domain/protocol/constants.ts b/src/domain/protocol/constants.ts index 66591eb..7903246 100644 --- a/src/domain/protocol/constants.ts +++ b/src/domain/protocol/constants.ts @@ -9,6 +9,7 @@ export const BLE_UUIDS = { receive: '7a78f7e9-1d6d-4d92-9ef0-1f89d3db21f4', transmit: '7a78f7ea-1d6d-4d92-9ef0-1f89d3db21f4', status: '7a78f7eb-1d6d-4d92-9ef0-1f89d3db21f4', + response: '7a78f7ec-1d6d-4d92-9ef0-1f89d3db21f4', } as const; export const BLE_DESCRIPTORS = { diff --git a/src/domain/protocol/responses.ts b/src/domain/protocol/responses.ts index ca977f1..d2cd3e3 100644 --- a/src/domain/protocol/responses.ts +++ b/src/domain/protocol/responses.ts @@ -18,8 +18,11 @@ export function parseStatus(raw: string): PcStatus | null { if (value.protocolVersion !== 1) return null; const desktopId = string(value.desktopId); if (!desktopId) return null; + if (value.responseTransport !== undefined && value.responseTransport !== 'read-v1') return null; const platform = value.platform === 'windows' || value.platform === 'macos' ? value.platform : null; - return { desktopId, displayName: string(value.displayName)?.trim() || 'Switchify PC', platform }; + return { desktopId, displayName: string(value.displayName)?.trim() || 'Switchify PC', platform, + ...(value.responseTransport === 'read-v1' ? { responseTransport: 'read-v1' as const } : {}), + }; } catch { return null; } diff --git a/src/domain/protocol/types.ts b/src/domain/protocol/types.ts index 9e4bad6..441fd7b 100644 --- a/src/domain/protocol/types.ts +++ b/src/domain/protocol/types.ts @@ -33,4 +33,4 @@ export type ProtocolResponse = | { kind: 'error'; id?: string; code: string; message: string } | { kind: 'invalid' }; -export type PcStatus = { desktopId: string; displayName: string; platform: PcPlatform }; +export type PcStatus = { desktopId: string; displayName: string; platform: PcPlatform; responseTransport?: 'read-v1' }; diff --git a/src/transport/ReactNativeBleTransport.test.ts b/src/transport/ReactNativeBleTransport.test.ts index 46cfb1f..9baf7ea 100644 --- a/src/transport/ReactNativeBleTransport.test.ts +++ b/src/transport/ReactNativeBleTransport.test.ts @@ -30,6 +30,33 @@ function manager(overrides: Record = {}): BleManager { } describe('ReactNativeBleTransport', () => { + it('uses peer-specific reads for negotiated Linux replies, never notifications', async () => { + const connected = device({ readCharacteristicForService: jest.fn(async (_service, characteristic) => ({ + value: characteristic.endsWith('7eb-1d6d-4d92-9ef0-1f89d3db21f4') + ? fromByteArray(new TextEncoder().encode(JSON.stringify({ protocolVersion: 1, desktopId: 'pc-1', responseTransport: 'read-v1' }))) + : '', + } as Characteristic)) }); + const native = manager({ connectToDevice: jest.fn(async () => connected) }); + const transport = new ReactNativeBleTransport(native, 'ios'); + await transport.connect('ble-1'); + const stop = transport.subscribe(jest.fn(), jest.fn()); + await transport.notificationsReady(); + expect(connected.monitorCharacteristicForService).not.toHaveBeenCalled(); + expect(connected.readDescriptorForService).not.toHaveBeenCalled(); + expect(connected.readCharacteristicForService).toHaveBeenCalledWith(expect.any(String), '7a78f7ec-1d6d-4d92-9ef0-1f89d3db21f4', expect.any(String)); + stop(); + expect(() => transport.subscribe(jest.fn(), jest.fn())).toThrow('Reconnect'); + await transport.disconnect(); + expect(native.cancelTransaction).toHaveBeenCalled(); + }); + + it('rejects unknown response transports without notification fallback', async () => { + const connected = device({ readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode('{"protocolVersion":1,"desktopId":"pc","responseTransport":"future"}')) } as Characteristic)) }); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios'); + await expect(transport.connect('ble-1')).rejects.toThrow('status is invalid'); + expect(connected.monitorCharacteristicForService).not.toHaveBeenCalled(); + expect(connected.cancelConnection).toHaveBeenCalled(); + }); it.each(['', 'private malformed', '{"protocolVersion":2}', '{"protocolVersion":1,"desktopId":"other-private"}'])('diagnoses rejected or nonmatching status (%s)', async (raw) => { const log = new DiagnosticLog(); let callback!: (error: Error | null, value: Device | null) => void; @@ -372,6 +399,7 @@ describe('ReactNativeBleTransport', () => { const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios'); await transport.connect('ble-1'); (connected.discoverAllServicesAndCharacteristics as jest.Mock).mockClear(); + (connected.readCharacteristicForService as jest.Mock).mockClear(); await expect(transport.verifyConnection('pc-1')).resolves.toBe(true); @@ -388,19 +416,20 @@ describe('ReactNativeBleTransport', () => { ['malformed status', { readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode('not json')) })) }], ['wrong desktop', { readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode('{"protocolVersion":1,"desktopId":"other","displayName":"Desk","platform":"windows"}')) })) }], ] as const)('reports a failed health check for %s', async (_label, overrides) => { - const connected = device(overrides as Partial); + const connected = device(); const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios'); await transport.connect('ble-1'); - + Object.assign(connected, overrides); await expect(transport.verifyConnection('pc-1')).resolves.toBe(false); }); it('bounds a connection health check to four seconds', async () => { jest.useFakeTimers(); try { - const connected = device({ readCharacteristicForService: jest.fn(() => new Promise(() => undefined)) }); + const connected = device(); const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios'); await transport.connect('ble-1'); + (connected.readCharacteristicForService as jest.Mock).mockImplementation(() => new Promise(() => undefined)); let settled = false; const result = transport.verifyConnection('pc-1').then((value) => { settled = true; return value; }); await Promise.resolve(); @@ -422,6 +451,7 @@ describe('ReactNativeBleTransport', () => { const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios'); await transport.connect('ble-1'); const result = transport.verifyConnection('pc-1'); + (connected.readCharacteristicForService as jest.Mock).mockClear(); await jest.advanceTimersByTimeAsync(4_000); await expect(result).resolves.toBe(false); diff --git a/src/transport/ReactNativeBleTransport.ts b/src/transport/ReactNativeBleTransport.ts index 513524f..b83f40a 100644 --- a/src/transport/ReactNativeBleTransport.ts +++ b/src/transport/ReactNativeBleTransport.ts @@ -7,6 +7,7 @@ import { parseStatus } from '@/domain/protocol/responses'; import type { ConnectionStage, ConnectionStageOutcome, DiagnosticLog } from '@/diagnostics/DiagnosticLog'; import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from './BleTransport'; import { bluetoothDeviceDisplayName, desktopDisplayName } from './desktopDisplayName'; +import { ReadResponsePoller } from './ReadResponsePoller'; export class ReactNativeBleTransport implements BleTransport { #manager: BleManager | null; @@ -22,6 +23,8 @@ export class ReactNativeBleTransport implements BleTransport { #resolutionCancel: ((error: Error) => Promise) | null = null; #writeSequence = 0; #writePoisoned = false; + #readReplies = false; + #responsePoller: ReadResponsePoller | null = null; constructor( manager: BleManager | null = null, @@ -162,6 +165,7 @@ export class ReactNativeBleTransport implements BleTransport { connected = await this.#stage('services', () => this.#bounded(connected!.discoverAllServicesAndCharacteristics()), operation); if (!active || operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); this.#device = connected; + this.#readReplies = desktop.responseTransport === 'read-v1'; this.#writePoisoned = false; succeed(desktop); }).catch((probeError: unknown) => { @@ -211,6 +215,11 @@ export class ReactNativeBleTransport implements BleTransport { connected = await this.#stage('services', () => this.#bounded(connected!.discoverAllServicesAndCharacteristics()), operation); if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); this.#device = connected; + const statusValue = await this.#bounded(connected.readCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.status)); + if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); + const status = statusValue.value ? parseStatus(new TextDecoder().decode(toByteArray(statusValue.value))) : null; + if (!status) throw new Error('Bluetooth discovery status is invalid.'); + this.#readReplies = status.responseTransport === 'read-v1'; this.#writePoisoned = false; } catch (error) { connectCancelled = true; @@ -225,6 +234,9 @@ export class ReactNativeBleTransport implements BleTransport { async disconnect(): Promise { this.#operation += 1; + this.#responsePoller?.stop(); + this.#responsePoller = null; + this.#readReplies = false; const cancelResolution = this.#resolutionCancel; if (cancelResolution) await cancelResolution(new Error('Bluetooth operation was cancelled.')); this.#cancelNativeOperations(); @@ -295,6 +307,21 @@ export class ReactNativeBleTransport implements BleTransport { } subscribe(onFrame: (frameBase64: string) => void, onError: (error: Error) => void): Unsubscribe { + if (this.#readReplies) { + if (this.#responsePoller || this.#writePoisoned) throw new Error('Reconnect before starting response reads again.'); + const device = this.#requireDevice(); + const operation = this.#operation; + const transaction = `switchify-read-${operation}`; + const poller = new ReadResponsePoller( + async () => (await device.readCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.response, transaction)).value, + async () => { await this.#managerOrCreate().cancelTransaction(transaction); }, + (frame) => { if (operation === this.#operation) onFrame(frame); }, + (error) => { if (operation === this.#operation) { this.#writePoisoned = true; onError(error); } }, + this.nativeTimeoutMs, + ); + this.#responsePoller = poller; + return () => { poller.stop(); if (this.#responsePoller === poller) this.#writePoisoned = true; }; + } const operation = this.#operation; let active = true; let failed = false; @@ -317,6 +344,11 @@ export class ReactNativeBleTransport implements BleTransport { } async notificationsReady(): Promise { + if (this.#readReplies) { + if (!this.#responsePoller) throw new Error('Bluetooth response reader has not started.'); + await this.#responsePoller.ready; + return; + } if (this.platform !== 'android') return; await this.#stage('notification_ready', async () => { const descriptor = await this.#bounded(this.#requireDevice().readDescriptorForService( diff --git a/src/transport/ReadResponsePoller.test.ts b/src/transport/ReadResponsePoller.test.ts new file mode 100644 index 0000000..7b76a59 --- /dev/null +++ b/src/transport/ReadResponsePoller.test.ts @@ -0,0 +1,67 @@ +import { ReadResponsePoller } from './ReadResponsePoller'; +import { parseStatus } from '@/domain/protocol/responses'; + +describe('Linux read responses', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('negotiates explicitly and preserves old desktops', () => { + const base = { protocolVersion: 1, desktopId: 'pc', platform: 'linux' }; + expect(parseStatus(JSON.stringify(base))?.responseTransport).toBeUndefined(); + expect(parseStatus(JSON.stringify({ ...base, responseTransport: 'read-v1' }))?.responseTransport).toBe('read-v1'); + for (const responseTransport of ['future', null, true]) expect(parseStatus(JSON.stringify({ ...base, responseTransport }))).toBeNull(); + }); + + it('polls serially, idles on empty values and stops without late delivery', async () => { + let finish!: (value: string) => void; + const read = jest.fn().mockResolvedValueOnce('').mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const cancel = jest.fn(async () => undefined), frame = jest.fn(), error = jest.fn(); + const poller = new ReadResponsePoller(read, cancel, frame, error, 1000); + await poller.ready; + await jest.advanceTimersByTimeAsync(99); + expect(read).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(401); + expect(read).toHaveBeenCalledTimes(2); + poller.stop(); + finish('e30='); + await jest.advanceTimersByTimeAsync(2000); + expect(frame).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledTimes(2); + }); + + it('delivers nonempty frames without the idle delay', async () => { + const read = jest.fn().mockResolvedValueOnce('e30=').mockResolvedValue(''); + const frame = jest.fn(); + const poller = new ReadResponsePoller(read, async () => undefined, frame, jest.fn(), 1000); + await poller.ready; + expect(frame).toHaveBeenCalledWith('e30='); + await jest.advanceTimersByTimeAsync(1); + expect(read).toHaveBeenCalledTimes(2); + poller.stop(); + }); + + it.each([null, '!', 'x'.repeat(244)])('fails closed on malformed or oversized value', async (value) => { + const error = jest.fn(); + const poller = new ReadResponsePoller(async () => value, async () => undefined, jest.fn(), error, 1000); + await expect(poller.ready).rejects.toThrow(); + expect(error).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(2000); + expect(error).toHaveBeenCalledTimes(1); + }); + + it('times out once, cancels the native read and ignores its late completion', async () => { + let finish!: (value: string) => void; + const cancel = jest.fn(async () => undefined), frame = jest.fn(), error = jest.fn(); + const poller = new ReadResponsePoller(() => new Promise((resolve) => { finish = resolve; }), cancel, frame, error, 1000); + const rejected = expect(poller.ready).rejects.toThrow(); + await jest.advanceTimersByTimeAsync(1000); + await rejected; + finish('e30='); + await jest.advanceTimersByTimeAsync(2000); + expect(error).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledTimes(1); + expect(frame).not.toHaveBeenCalled(); + }); +}); diff --git a/src/transport/ReadResponsePoller.ts b/src/transport/ReadResponsePoller.ts new file mode 100644 index 0000000..dc9800d --- /dev/null +++ b/src/transport/ReadResponsePoller.ts @@ -0,0 +1,53 @@ +import { toByteArray } from 'base64-js'; + +/** One consuming ATT read at a time. Any failure requires a fresh connection. */ +export class ReadResponsePoller { + readonly ready: Promise; + #active = true; + #timer: ReturnType | undefined; + #resolveReady!: () => void; + #rejectReady!: (error: Error) => void; + + constructor( + private readonly read: () => Promise, + private readonly cancelRead: () => Promise, + private readonly onFrame: (frame: string) => void, + private readonly onError: (error: Error) => void, + private readonly timeoutMs: number, + ) { + this.ready = new Promise((resolve, reject) => { this.#resolveReady = resolve; this.#rejectReady = reject; }); + void this.ready.catch(() => undefined); + void this.#poll(); + } + + stop(): void { + if (!this.#active) return; + this.#active = false; + clearTimeout(this.#timer); + this.#rejectReady(new Error('Bluetooth response reading stopped.')); + void this.cancelRead().catch(() => undefined); + } + + async #poll(): Promise { + if (!this.#active) return; + const fail = () => { + if (!this.#active) return; + this.stop(); + this.onError(new Error('Bluetooth response reading failed. Reconnect to the PC.')); + }; + this.#timer = setTimeout(fail, this.timeoutMs); + try { + const value = await this.read(); + if (!this.#active) return; + clearTimeout(this.#timer); + if (value === null || value.length > 240 || (value !== '' && (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) || toByteArray(value).length > 180))) { + fail(); return; + } + this.#resolveReady(); + if (value) this.onFrame(value); + if (this.#active) this.#timer = setTimeout(() => { void this.#poll(); }, value ? 0 : 100); + } catch { + fail(); + } + } +}