From 753c675f59d9646839b31ffdb2cfc2d2de2bf1c1 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:35:26 +0300 Subject: [PATCH 1/2] Bound clipboard sync retries and unify Windows subprocess flags Review findings on the v0.11.6/v0.11.7 range. Five fixes; one reported finding was wrong and is explained below. transfers/clipboard: - synchronizePcClipboard recorded #lastSentClipboard only on ACK, which is deliberate (test_failed_pc_clipboard_sync_remains_retryable wants a failed sync retried). But pollPcClipboard runs every 1800ms and skips only text recorded as sent, and the ACK wait is 1500ms, so an unacknowledged sync re-sent the same text every tick for as long as it stayed on the PC clipboard -- an unbounded stream of control messages with a status line flapping over it. Retries are now capped at three attempts per text, after which the text is recorded as sent and the status says so. Retryability is preserved; the loop is not. - pasteText always followed the clipboard sync with a full textInjectionMessages() injection. That chunks at 300 UTF-8 bytes and awaits each chunk, so a paste at the 256 KiB limit was roughly 875 sequential round trips. Injection is now capped at 8 KiB; above that the clipboard is still synchronized and the status tells the user to paste on the device. pasteTypedText rejects oversized input with the same explanation, since typing is all it does. - resetClipboardSessionState cleared the visible fallback text box on every connect, discarding text the user had typed while disconnected. The two cached device-clipboard fields are still cleared; the textarea is user input, not cached state. Windows subprocess flags: - network_access.apply() runs PowerShell New-NetFirewallRule on Windows with no creation flags, so a console window appeared every time LAN access was applied or removed. This is the same defect the new adb helper was added to fix, and the most visible instance of it. - That helper was the fourth expression of the same decision, and the three existing ones disagreed: adb/client.py fell back to the real constant, desktop/controller.py and desktop/support.py fell back to 0 (no flag), and network_access.py had nothing. All four now use droid_web_display/process_utils.subprocess_creation_kwargs. adb/client._subprocess_creation_kwargs is kept as a thin delegate because the Windows regression test monkeypatches it by name. Retracted finding: I reported that copyAndroidClipboard had lost the transient user activation needed for navigator.clipboard.writeText by moving the write into the device-message loop. That is wrong. bindAndroidCopyWriteThrough in main.ts already performs the write inside the originating gesture, polling the DOM for the reply and falling back to document.execCommand("copy"). I had not read main.ts when I filed it. No change was made there. Tests: the session-state contract asserted the textarea is cleared; it now asserts the opposite, with the reason. Added coverage for the retry bound and the injection cap, against both src and dist. No node/npm or pytest here. dist/assets/controller.js was hand-applied and the manifest regenerated; verify_static_client passes over all 92 files. All 8 clipboard contract tests, both clipboard-copy server-mode tests, the toolbar layout tests and the Windows creation-kwargs test were executed directly and pass. The fixture-based subprocess tests and anything needing a browser were not run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfT5Z3Z24bjC8qtqERGpMQ --- apps/web-client/dist-manifest.json | 473 +++++++++++++++++- apps/web-client/dist/assets/controller.js | 52 +- apps/web-client/src/controller.ts | 51 +- droid_web_display/adb/client.py | 8 +- droid_web_display/desktop/controller.py | 4 +- droid_web_display/desktop/support.py | 4 +- droid_web_display/network_access.py | 13 +- droid_web_display/process_utils.py | 25 + .../test_clipboard_sync_regression.py | 30 +- 9 files changed, 631 insertions(+), 29 deletions(-) create mode 100644 droid_web_display/process_utils.py diff --git a/apps/web-client/dist-manifest.json b/apps/web-client/dist-manifest.json index 85a0380..d2dc3ad 100644 --- a/apps/web-client/dist-manifest.json +++ b/apps/web-client/dist-manifest.json @@ -1 +1,472 @@ -{"schemaVersion":1,"packageVersion":"0.11.2","generatedBy":"apps/web-client/tools/build.mjs","files":[{"path":"assets/api.d.ts","bytes":7867,"sha256":"0de04eb0eb1f794c7dc509a73dbd6cfe73636a3301951b1e7b591d6b81f8228f"},{"path":"assets/api.js","bytes":10545,"sha256":"46558e1f056e2976f827b3db89b31399bb99a67fa5d6a3bdb5df3d1fe2fbe3a2"},{"path":"assets/api.js.map","bytes":9625,"sha256":"ecb4a621a9ea74f3f99ef8a42ec5f6b517c69acee8e07218cc67c5d73a13e840"},{"path":"assets/audio-player.d.ts","bytes":847,"sha256":"3929bbdb74475c04416a0ee226eca7b091bca29c79fc1f2a4e3f98a276b17829"},{"path":"assets/audio-player.js","bytes":4861,"sha256":"1b88f6fbe635c9a782fd045399715a17a6d2c59ee36b9ca75a83517ab8a5d852"},{"path":"assets/audio-player.js.map","bytes":4963,"sha256":"fdc6daf346924fa61aca418d40a1225cf5759b52475277a238741f8d2b7d6088"},{"path":"assets/auth-controller.d.ts","bytes":1467,"sha256":"53fe1f6196b1e68800c547ef522f0deb02104a783fd4f63164a17d69c9f27fc4"},{"path":"assets/auth-controller.js","bytes":8041,"sha256":"34e6bb0595008cbe7786dc7f634da286379e46e16e4c1494aa24744c3b9156c0"},{"path":"assets/auth-controller.js.map","bytes":8076,"sha256":"752523af0fa93013ab6dbf78459b3027b4e29997b89af67b1f51e78d60c3a93c"},{"path":"assets/auto-download-controller.d.ts","bytes":1393,"sha256":"ed32f90b79f8c9ff43af33934e273a7040449f9e4ea02e41009d7e3426efcc66"},{"path":"assets/auto-download-controller.js","bytes":11291,"sha256":"57337ebf7a023afa1c12f6788cb27779aef2d3644bdd9116a29e0a462eef0598"},{"path":"assets/auto-download-controller.js.map","bytes":11320,"sha256":"4f5f221906ec6494312293b56d3a12a8a8b3b626531e2956c8777dfc187dcd91"},{"path":"assets/browser-support.d.ts","bytes":766,"sha256":"224c57a68b358da8fcb76277d3202738e70fa5ee1946fba0bb8d91077fb4e4f8"},{"path":"assets/browser-support.js","bytes":671,"sha256":"010a0b76cbe8bb6ae9e20ff5a60836b4fcfe94010678ee09fcaf349380675529"},{"path":"assets/browser-support.js.map","bytes":719,"sha256":"412b951eb2bbf8b3614024506c288498b5033e62c07a49385cd754101f0e8d74"},{"path":"assets/controller.d.ts","bytes":4252,"sha256":"9d4db1417756dee4ba4d7681428a0b0e0be01a042f317b67d879aeab3e6ded4a"},{"path":"assets/controller.js","bytes":54718,"sha256":"928926e0f48f04769c21edeb6347ed2751f456b55c4aa48da582d4af3cbfb486"},{"path":"assets/controller.js.map","bytes":48223,"sha256":"40ca9fb61bfd194b5d9fc80c27c88c3a6d1839872f8e9ebdd51e82712ce13fb7"},{"path":"assets/display-config.d.ts","bytes":1412,"sha256":"37e0c88b13717cd04ea6ea5c1412a01df5f14ac23ea9c085d5baa72a7d09ad1c"},{"path":"assets/display-config.js","bytes":5470,"sha256":"8f5d3062eaec98823cd14c0f113c44a70397fd41e777800c0e8afa92f2381261"},{"path":"assets/display-config.js.map","bytes":4879,"sha256":"93640726bf114c650354fc0024fd63f520f2b00facd3ba35c5aa828f0949ad6f"},{"path":"assets/input.d.ts","bytes":1204,"sha256":"a3b75762c1552ba294591b821d0c333851b14afd0297b1a0f0e3ddef12f5f4b2"},{"path":"assets/input.js","bytes":3522,"sha256":"095f6bd62c75597776522c9045faa51fe8ab2fb74653d3ce17c5589bbd6ff10f"},{"path":"assets/input.js.map","bytes":3991,"sha256":"e742a1a23316541d5817125104daa75f12bb3a9b40b0b5e04db117411ab3b9fe"},{"path":"assets/main.d.ts","bytes":11,"sha256":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"path":"assets/main.js","bytes":14522,"sha256":"746ee71599bdc6625c5eccaecef8869ebd3b17daf434a490e7830635f7b59b3f"},{"path":"assets/main.js.map","bytes":8360,"sha256":"7b55a69966b32924b33d62ac7fd7d468fff3c81e211f82b4976db57716e391f7"},{"path":"assets/network-controller.d.ts","bytes":1313,"sha256":"1148e42730c2d3dc367aec36a74cfb52dbd833d1c1c158e2f6a6dd20967e8447"},{"path":"assets/network-controller.js","bytes":8287,"sha256":"738a787baf0e3820ae11b6c022a0fbfb65563fbc115524fd3218e04495085b13"},{"path":"assets/network-controller.js.map","bytes":8582,"sha256":"713bd634c35efb9acd3b64488f6b16b35e384d5c942e8ed39ba8d1a1547e13df"},{"path":"assets/running-app-controller.d.ts","bytes":863,"sha256":"97907288db5c5327f06052ce31c47a7779cb53809323cf9486b59603c4b1e40e"},{"path":"assets/running-app-controller.js","bytes":13136,"sha256":"20dfb262c7ba35e32daf1a8dc439a1bdcdcc7a0cf6f26fd7adf85f1ded9e76c1"},{"path":"assets/running-app-controller.js.map","bytes":12847,"sha256":"52b65b0ee2d86aff2b8c5e86441648961aa1d21c88e663250a2c43df848e5980"},{"path":"assets/transfer-controller.d.ts","bytes":2919,"sha256":"af2de615137481a9eae6d8d7a5998835a2d848bfcb003e5431fd1d921823bd7f"},{"path":"assets/transfer-controller.js","bytes":39191,"sha256":"3d068756e035e7598800ed12a7269a8bc862f699529295d56dda2724f048bb56"},{"path":"assets/transfer-controller.js.map","bytes":36404,"sha256":"afc1fbd63898f171f8dd4eff2eed9c145ec30d5286ff692043949e6bd54e4d2c"},{"path":"assets/types.d.ts","bytes":10197,"sha256":"20bfe1bec434dbd8bbd9e0948271f771a3871064e08d258a78b2fb8942985045"},{"path":"assets/types.js","bytes":44,"sha256":"01ae2a5b120382f9a648ced7ee8507493a134f216d100fc61600c6c9738235d2"},{"path":"assets/types.js.map","bytes":105,"sha256":"17307f7387fbcf31645bdc6c61dd25c80edefb6fbacbb2909f503b879227b631"},{"path":"assets/video-renderer.d.ts","bytes":1419,"sha256":"af3401568c03f92f23eb240a17dc27f5458db4f25726ec1f989b860dfb734480"},{"path":"assets/video-renderer.js","bytes":7848,"sha256":"ed82e5181cad5f2cbd65ebc14414d6611501a20f00feae0ed023e6be8d06c14e"},{"path":"assets/video-renderer.js.map","bytes":7412,"sha256":"22697668c013202784c14a38d5dc6c0d17392302e19d91d6dd337e06b1773fc8"},{"path":"assets/websocket-transport.d.ts","bytes":706,"sha256":"3bf5358eab85fac59bd836cdb334f2a8c25a53fa5e9868a8db8879dff1c155e5"},{"path":"assets/websocket-transport.js","bytes":5490,"sha256":"81dae6f375c11bce01ac585e61e7bf75fef44b703ea8e41b8426fd6cb2a6b7ec"},{"path":"assets/websocket-transport.js.map","bytes":5366,"sha256":"f420b77d19cd66926a9e8f0aa3df4450eee1de9dcf923da5ff3bb55d414325fa"},{"path":"droidwebdisplay-main-drawer-core.css","bytes":27104,"sha256":"8bc304be85b8fcdd8f21d51ab708b5a24e1102938dc16c651ec01bcb6fb6a3c7"},{"path":"droidwebdisplay-main-drawer.css","bytes":5670,"sha256":"fdf37a0bcb9ad994d2771f5ae8c6aa8f1871f401a206d28e76e62df1fa4bc0aa"},{"path":"droidwebdisplay-main-drawer.js","bytes":26484,"sha256":"57291e2c349f95159d731dd90010f42bf0748fc019a4c32e922d6fed72af097d"},{"path":"favicon.svg","bytes":328,"sha256":"0f7ee6fcc369edd2ca0fcfbe571c41e034677102d6b5b619cdaf878fdb279301"},{"path":"index.html","bytes":33335,"sha256":"f138aeee999e7765ae1c415d80ad89f6acab48548911f16234f7dc739ad6e897"},{"path":"styles.css","bytes":31432,"sha256":"0bc546956b467f8d5d072409732a5e86154dd67f7a5d441e229a6967cabfd701"},{"path":"vendor/scrcpy-protocol/src/common/async-byte-reader.d.ts","bytes":504,"sha256":"079350d7c949cb0a9580d6fa9d84bb5ccac01b26f9f3596861371fbca69c83fb"},{"path":"vendor/scrcpy-protocol/src/common/async-byte-reader.js","bytes":2561,"sha256":"6dfe495ec309ebf8775e5a19d7c69318d753cb8cb736e076e41b6a0c099823c4"},{"path":"vendor/scrcpy-protocol/src/common/async-byte-reader.js.map","bytes":2720,"sha256":"f6184e646d46fd24d91cde7ba9d6a1df6ffa09e0fc03a6bdfb7b840fa14752ba"},{"path":"vendor/scrcpy-protocol/src/common/binary.d.ts","bytes":1095,"sha256":"0d5cb1f303300fd88b5eb6ddb2c6dc70f84c983ff30f09bd2c1957cb049f948d"},{"path":"vendor/scrcpy-protocol/src/common/binary.js","bytes":4566,"sha256":"40706609995a47dcbb1f2bf20da3dcb3696efa081dac54c033229a3c067c3474"},{"path":"vendor/scrcpy-protocol/src/common/binary.js.map","bytes":5496,"sha256":"63d5024d0699d5c41410161cc5369fd0802dd2a8b24d5dab660aa032cee19f72"},{"path":"vendor/scrcpy-protocol/src/common/errors.d.ts","bytes":580,"sha256":"92d70ebbca76d09f48732f2f098ab103d26f264708a6e6261ff0d7ae2462c0b3"},{"path":"vendor/scrcpy-protocol/src/common/errors.js","bytes":840,"sha256":"4af7bf2aa4fc805e64528813746253c154ce05c2272b5fb2329aa63e1c56f3fb"},{"path":"vendor/scrcpy-protocol/src/common/errors.js.map","bytes":633,"sha256":"98941e677dd3bd055903a6bfc2e589cdb94679a9b9808f8e6f9a9b7a4dcc6fea"},{"path":"vendor/scrcpy-protocol/src/common/transport.d.ts","bytes":759,"sha256":"93f3bae3bb94e6b194e4df91910aa276c9ef69e8e34f2e47298563a7dee32612"},{"path":"vendor/scrcpy-protocol/src/common/transport.js","bytes":48,"sha256":"b7bb187ea1bf4d71015d2996c266e05de2320643320e9812e482925ecf334011"},{"path":"vendor/scrcpy-protocol/src/common/transport.js.map","bytes":123,"sha256":"3babf01144639d913594870ced9d6899006705414836a75e798d07518fc699a1"},{"path":"vendor/scrcpy-protocol/src/common/types.d.ts","bytes":634,"sha256":"33272a2438a6a2a16f26fe4ad52647cfec047f9010360c4bf0f9ea3594e004ac"},{"path":"vendor/scrcpy-protocol/src/common/types.js","bytes":44,"sha256":"01ae2a5b120382f9a648ced7ee8507493a134f216d100fc61600c6c9738235d2"},{"path":"vendor/scrcpy-protocol/src/common/types.js.map","bytes":115,"sha256":"bbabd0ed1cc20d31fe44374fd8b0d830dab18c6de2b695a0dcac5026bf4e41d3"},{"path":"vendor/scrcpy-protocol/src/index.d.ts","bytes":235,"sha256":"81b5042459b96bd118b00aa51d894b9219186f76331558070468ae564f69c89d"},{"path":"vendor/scrcpy-protocol/src/index.js","bytes":268,"sha256":"e9ca80e46655bd1a830210ec2495a3ff6445ac816cbac03b76b427f228df279a"},{"path":"vendor/scrcpy-protocol/src/index.js.map","bytes":241,"sha256":"b74a1039b8a04948cc823be75849defdd823f61a102dcfe8a4d3f46f8c877f30"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/adapter.d.ts","bytes":1021,"sha256":"6c55edebbf07b87952ce9fa65ae61aa35e6ec5360b5c82eb46fe468755e3c6fe"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/adapter.js","bytes":5078,"sha256":"7dc0b972037f817fa3b8e3ec983646f0d3b50f06bc075e9f0c80d336a2d60142"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/adapter.js.map","bytes":4236,"sha256":"58d319f6384c4158b0cbc0c97db68130abc22a0d14f55ee22447c0351b90bb57"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/constants.d.ts","bytes":1319,"sha256":"e42eaf42e4a1aa6876990d1c8d383692e6ea473c5bb93d94daf3be539a46e768"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/constants.js","bytes":1743,"sha256":"b17dcda45c891b25e1eb382151048e37d220e25fa8fd2a208ee9a81fb0e6b22f"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/constants.js.map","bytes":1578,"sha256":"8b9720613fb8edbc34f78287a4f5645cf0bab5575e93c9eb20b600f14757a2b6"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/control.d.ts","bytes":3110,"sha256":"8f9ad2937ecfa76333da5e27df5a15f40b03f2e9b7140ca7d45fe5f41123337e"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/control.js","bytes":7524,"sha256":"0d2d46b93174b841f0ca69df212d97c781b0045004834805b4e1393a62427dc6"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/control.js.map","bytes":5786,"sha256":"5745603c2460d596a390e16e9a0687d4cac8366d92d67a3f0511d0303eff9aaa"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/device.d.ts","bytes":690,"sha256":"1d7d9a195a14d9be22319c800e22fb13a67402f74c7662612e2e26a8bb80844c"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/device.js","bytes":1914,"sha256":"8abf7d414d26ba21b38709b9016a6c5fb89e0ecb0908b01a9bf3e497dd99e731"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/device.js.map","bytes":1684,"sha256":"0eaf2239c4b19bfac8aaed54080231716cbb9440207a9bc3086865c2d02d0ae5"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/h264.d.ts","bytes":400,"sha256":"6d3ca9b66d344a200f7f1d50801cfe75506f0f47bb25b7d3b632523b1fdc8459"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/h264.js","bytes":2213,"sha256":"918ba61e5ac1c7f186102e8fae149d0855b171bd1f1d126df27eee027492a43d"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/h264.js.map","bytes":2617,"sha256":"04bc2c0a25445c7fad35fad73e091ebe91d669bd185f465041c03c6acb512d65"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/handshake.d.ts","bytes":291,"sha256":"e0b28c022282702b68fe05f6122032e24440825857afc057dabd66886ffd360b"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/handshake.js","bytes":689,"sha256":"dfc2d0038117c8821715fcc442abc93dcd2b117cba862b000fa988b90c254ace"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/handshake.js.map","bytes":834,"sha256":"5c39d2d343d6d72f8895819527f0de54265e91ccaebc749049255643d263a556"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/index.d.ts","bytes":208,"sha256":"83bb34a9a93cc19775845e021cb04ebbab146486f8e55a9e69e0b1a80a0ddf9b"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/index.js","bytes":241,"sha256":"9470acdc6865f9237ad6c1385d21a2a9ad2752ecd62bdd137e0dd296cb3ff252"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/index.js.map","bytes":274,"sha256":"18572ffc879b1367e1584db05f41e3f3940a816d06089d132a482cb1f3d3990b"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/media.d.ts","bytes":836,"sha256":"7cbb9223f6fcfa665361f3d15450774baa920667fc93a21ff42cd8cca91b1748"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/media.js","bytes":4595,"sha256":"21aae13478490fcc5311f4442fc5bcb4e5a820c263d35b0b853948970f8772f0"},{"path":"vendor/scrcpy-protocol/src/versions/v4_1/media.js.map","bytes":4275,"sha256":"a7b8922f316ec12b1729f53fa5bf900fae02839a4a9a27de40549e9341a584c7"}]} +{ + "schemaVersion": 1, + "packageVersion": "0.11.2", + "generatedBy": "apps/web-client/tools/build.mjs", + "files": [ + { + "path": "assets/api.d.ts", + "bytes": 7867, + "sha256": "0de04eb0eb1f794c7dc509a73dbd6cfe73636a3301951b1e7b591d6b81f8228f" + }, + { + "path": "assets/api.js", + "bytes": 10545, + "sha256": "46558e1f056e2976f827b3db89b31399bb99a67fa5d6a3bdb5df3d1fe2fbe3a2" + }, + { + "path": "assets/api.js.map", + "bytes": 9625, + "sha256": "ecb4a621a9ea74f3f99ef8a42ec5f6b517c69acee8e07218cc67c5d73a13e840" + }, + { + "path": "assets/audio-player.d.ts", + "bytes": 847, + "sha256": "3929bbdb74475c04416a0ee226eca7b091bca29c79fc1f2a4e3f98a276b17829" + }, + { + "path": "assets/audio-player.js", + "bytes": 4861, + "sha256": "1b88f6fbe635c9a782fd045399715a17a6d2c59ee36b9ca75a83517ab8a5d852" + }, + { + "path": "assets/audio-player.js.map", + "bytes": 4963, + "sha256": "fdc6daf346924fa61aca418d40a1225cf5759b52475277a238741f8d2b7d6088" + }, + { + "path": "assets/auth-controller.d.ts", + "bytes": 1467, + "sha256": "53fe1f6196b1e68800c547ef522f0deb02104a783fd4f63164a17d69c9f27fc4" + }, + { + "path": "assets/auth-controller.js", + "bytes": 8041, + "sha256": "34e6bb0595008cbe7786dc7f634da286379e46e16e4c1494aa24744c3b9156c0" + }, + { + "path": "assets/auth-controller.js.map", + "bytes": 8076, + "sha256": "752523af0fa93013ab6dbf78459b3027b4e29997b89af67b1f51e78d60c3a93c" + }, + { + "path": "assets/auto-download-controller.d.ts", + "bytes": 1393, + "sha256": "ed32f90b79f8c9ff43af33934e273a7040449f9e4ea02e41009d7e3426efcc66" + }, + { + "path": "assets/auto-download-controller.js", + "bytes": 11291, + "sha256": "57337ebf7a023afa1c12f6788cb27779aef2d3644bdd9116a29e0a462eef0598" + }, + { + "path": "assets/auto-download-controller.js.map", + "bytes": 11320, + "sha256": "4f5f221906ec6494312293b56d3a12a8a8b3b626531e2956c8777dfc187dcd91" + }, + { + "path": "assets/browser-support.d.ts", + "bytes": 766, + "sha256": "224c57a68b358da8fcb76277d3202738e70fa5ee1946fba0bb8d91077fb4e4f8" + }, + { + "path": "assets/browser-support.js", + "bytes": 671, + "sha256": "010a0b76cbe8bb6ae9e20ff5a60836b4fcfe94010678ee09fcaf349380675529" + }, + { + "path": "assets/browser-support.js.map", + "bytes": 719, + "sha256": "412b951eb2bbf8b3614024506c288498b5033e62c07a49385cd754101f0e8d74" + }, + { + "path": "assets/controller.d.ts", + "bytes": 4252, + "sha256": "9d4db1417756dee4ba4d7681428a0b0e0be01a042f317b67d879aeab3e6ded4a" + }, + { + "path": "assets/controller.js", + "bytes": 57105, + "sha256": "4692db581e43f0b65341481b3a7f6a98ca3ad700c753bd4157a1a5ac7ba2c3fc" + }, + { + "path": "assets/controller.js.map", + "bytes": 48223, + "sha256": "40ca9fb61bfd194b5d9fc80c27c88c3a6d1839872f8e9ebdd51e82712ce13fb7" + }, + { + "path": "assets/display-config.d.ts", + "bytes": 1412, + "sha256": "37e0c88b13717cd04ea6ea5c1412a01df5f14ac23ea9c085d5baa72a7d09ad1c" + }, + { + "path": "assets/display-config.js", + "bytes": 5470, + "sha256": "8f5d3062eaec98823cd14c0f113c44a70397fd41e777800c0e8afa92f2381261" + }, + { + "path": "assets/display-config.js.map", + "bytes": 4879, + "sha256": "93640726bf114c650354fc0024fd63f520f2b00facd3ba35c5aa828f0949ad6f" + }, + { + "path": "assets/input.d.ts", + "bytes": 1204, + "sha256": "a3b75762c1552ba294591b821d0c333851b14afd0297b1a0f0e3ddef12f5f4b2" + }, + { + "path": "assets/input.js", + "bytes": 3522, + "sha256": "095f6bd62c75597776522c9045faa51fe8ab2fb74653d3ce17c5589bbd6ff10f" + }, + { + "path": "assets/input.js.map", + "bytes": 3991, + "sha256": "e742a1a23316541d5817125104daa75f12bb3a9b40b0b5e04db117411ab3b9fe" + }, + { + "path": "assets/main.d.ts", + "bytes": 11, + "sha256": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881" + }, + { + "path": "assets/main.js", + "bytes": 14522, + "sha256": "746ee71599bdc6625c5eccaecef8869ebd3b17daf434a490e7830635f7b59b3f" + }, + { + "path": "assets/main.js.map", + "bytes": 8360, + "sha256": "7b55a69966b32924b33d62ac7fd7d468fff3c81e211f82b4976db57716e391f7" + }, + { + "path": "assets/network-controller.d.ts", + "bytes": 1313, + "sha256": "1148e42730c2d3dc367aec36a74cfb52dbd833d1c1c158e2f6a6dd20967e8447" + }, + { + "path": "assets/network-controller.js", + "bytes": 8287, + "sha256": "738a787baf0e3820ae11b6c022a0fbfb65563fbc115524fd3218e04495085b13" + }, + { + "path": "assets/network-controller.js.map", + "bytes": 8582, + "sha256": "713bd634c35efb9acd3b64488f6b16b35e384d5c942e8ed39ba8d1a1547e13df" + }, + { + "path": "assets/running-app-controller.d.ts", + "bytes": 863, + "sha256": "97907288db5c5327f06052ce31c47a7779cb53809323cf9486b59603c4b1e40e" + }, + { + "path": "assets/running-app-controller.js", + "bytes": 13136, + "sha256": "20dfb262c7ba35e32daf1a8dc439a1bdcdcc7a0cf6f26fd7adf85f1ded9e76c1" + }, + { + "path": "assets/running-app-controller.js.map", + "bytes": 12847, + "sha256": "52b65b0ee2d86aff2b8c5e86441648961aa1d21c88e663250a2c43df848e5980" + }, + { + "path": "assets/transfer-controller.d.ts", + "bytes": 2919, + "sha256": "af2de615137481a9eae6d8d7a5998835a2d848bfcb003e5431fd1d921823bd7f" + }, + { + "path": "assets/transfer-controller.js", + "bytes": 39191, + "sha256": "3d068756e035e7598800ed12a7269a8bc862f699529295d56dda2724f048bb56" + }, + { + "path": "assets/transfer-controller.js.map", + "bytes": 36404, + "sha256": "afc1fbd63898f171f8dd4eff2eed9c145ec30d5286ff692043949e6bd54e4d2c" + }, + { + "path": "assets/types.d.ts", + "bytes": 10197, + "sha256": "20bfe1bec434dbd8bbd9e0948271f771a3871064e08d258a78b2fb8942985045" + }, + { + "path": "assets/types.js", + "bytes": 44, + "sha256": "01ae2a5b120382f9a648ced7ee8507493a134f216d100fc61600c6c9738235d2" + }, + { + "path": "assets/types.js.map", + "bytes": 105, + "sha256": "17307f7387fbcf31645bdc6c61dd25c80edefb6fbacbb2909f503b879227b631" + }, + { + "path": "assets/video-renderer.d.ts", + "bytes": 1419, + "sha256": "af3401568c03f92f23eb240a17dc27f5458db4f25726ec1f989b860dfb734480" + }, + { + "path": "assets/video-renderer.js", + "bytes": 7848, + "sha256": "ed82e5181cad5f2cbd65ebc14414d6611501a20f00feae0ed023e6be8d06c14e" + }, + { + "path": "assets/video-renderer.js.map", + "bytes": 7412, + "sha256": "22697668c013202784c14a38d5dc6c0d17392302e19d91d6dd337e06b1773fc8" + }, + { + "path": "assets/websocket-transport.d.ts", + "bytes": 706, + "sha256": "3bf5358eab85fac59bd836cdb334f2a8c25a53fa5e9868a8db8879dff1c155e5" + }, + { + "path": "assets/websocket-transport.js", + "bytes": 5490, + "sha256": "81dae6f375c11bce01ac585e61e7bf75fef44b703ea8e41b8426fd6cb2a6b7ec" + }, + { + "path": "assets/websocket-transport.js.map", + "bytes": 5366, + "sha256": "f420b77d19cd66926a9e8f0aa3df4450eee1de9dcf923da5ff3bb55d414325fa" + }, + { + "path": "droidwebdisplay-main-drawer-core.css", + "bytes": 27104, + "sha256": "8bc304be85b8fcdd8f21d51ab708b5a24e1102938dc16c651ec01bcb6fb6a3c7" + }, + { + "path": "droidwebdisplay-main-drawer.css", + "bytes": 5670, + "sha256": "fdf37a0bcb9ad994d2771f5ae8c6aa8f1871f401a206d28e76e62df1fa4bc0aa" + }, + { + "path": "droidwebdisplay-main-drawer.js", + "bytes": 26484, + "sha256": "57291e2c349f95159d731dd90010f42bf0748fc019a4c32e922d6fed72af097d" + }, + { + "path": "favicon.svg", + "bytes": 328, + "sha256": "0f7ee6fcc369edd2ca0fcfbe571c41e034677102d6b5b619cdaf878fdb279301" + }, + { + "path": "index.html", + "bytes": 33335, + "sha256": "f138aeee999e7765ae1c415d80ad89f6acab48548911f16234f7dc739ad6e897" + }, + { + "path": "styles.css", + "bytes": 31432, + "sha256": "0bc546956b467f8d5d072409732a5e86154dd67f7a5d441e229a6967cabfd701" + }, + { + "path": "vendor/scrcpy-protocol/src/common/async-byte-reader.d.ts", + "bytes": 504, + "sha256": "079350d7c949cb0a9580d6fa9d84bb5ccac01b26f9f3596861371fbca69c83fb" + }, + { + "path": "vendor/scrcpy-protocol/src/common/async-byte-reader.js", + "bytes": 2561, + "sha256": "6dfe495ec309ebf8775e5a19d7c69318d753cb8cb736e076e41b6a0c099823c4" + }, + { + "path": "vendor/scrcpy-protocol/src/common/async-byte-reader.js.map", + "bytes": 2720, + "sha256": "f6184e646d46fd24d91cde7ba9d6a1df6ffa09e0fc03a6bdfb7b840fa14752ba" + }, + { + "path": "vendor/scrcpy-protocol/src/common/binary.d.ts", + "bytes": 1095, + "sha256": "0d5cb1f303300fd88b5eb6ddb2c6dc70f84c983ff30f09bd2c1957cb049f948d" + }, + { + "path": "vendor/scrcpy-protocol/src/common/binary.js", + "bytes": 4566, + "sha256": "40706609995a47dcbb1f2bf20da3dcb3696efa081dac54c033229a3c067c3474" + }, + { + "path": "vendor/scrcpy-protocol/src/common/binary.js.map", + "bytes": 5496, + "sha256": "63d5024d0699d5c41410161cc5369fd0802dd2a8b24d5dab660aa032cee19f72" + }, + { + "path": "vendor/scrcpy-protocol/src/common/errors.d.ts", + "bytes": 580, + "sha256": "92d70ebbca76d09f48732f2f098ab103d26f264708a6e6261ff0d7ae2462c0b3" + }, + { + "path": "vendor/scrcpy-protocol/src/common/errors.js", + "bytes": 840, + "sha256": "4af7bf2aa4fc805e64528813746253c154ce05c2272b5fb2329aa63e1c56f3fb" + }, + { + "path": "vendor/scrcpy-protocol/src/common/errors.js.map", + "bytes": 633, + "sha256": "98941e677dd3bd055903a6bfc2e589cdb94679a9b9808f8e6f9a9b7a4dcc6fea" + }, + { + "path": "vendor/scrcpy-protocol/src/common/transport.d.ts", + "bytes": 759, + "sha256": "93f3bae3bb94e6b194e4df91910aa276c9ef69e8e34f2e47298563a7dee32612" + }, + { + "path": "vendor/scrcpy-protocol/src/common/transport.js", + "bytes": 48, + "sha256": "b7bb187ea1bf4d71015d2996c266e05de2320643320e9812e482925ecf334011" + }, + { + "path": "vendor/scrcpy-protocol/src/common/transport.js.map", + "bytes": 123, + "sha256": "3babf01144639d913594870ced9d6899006705414836a75e798d07518fc699a1" + }, + { + "path": "vendor/scrcpy-protocol/src/common/types.d.ts", + "bytes": 634, + "sha256": "33272a2438a6a2a16f26fe4ad52647cfec047f9010360c4bf0f9ea3594e004ac" + }, + { + "path": "vendor/scrcpy-protocol/src/common/types.js", + "bytes": 44, + "sha256": "01ae2a5b120382f9a648ced7ee8507493a134f216d100fc61600c6c9738235d2" + }, + { + "path": "vendor/scrcpy-protocol/src/common/types.js.map", + "bytes": 115, + "sha256": "bbabd0ed1cc20d31fe44374fd8b0d830dab18c6de2b695a0dcac5026bf4e41d3" + }, + { + "path": "vendor/scrcpy-protocol/src/index.d.ts", + "bytes": 235, + "sha256": "81b5042459b96bd118b00aa51d894b9219186f76331558070468ae564f69c89d" + }, + { + "path": "vendor/scrcpy-protocol/src/index.js", + "bytes": 268, + "sha256": "e9ca80e46655bd1a830210ec2495a3ff6445ac816cbac03b76b427f228df279a" + }, + { + "path": "vendor/scrcpy-protocol/src/index.js.map", + "bytes": 241, + "sha256": "b74a1039b8a04948cc823be75849defdd823f61a102dcfe8a4d3f46f8c877f30" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/adapter.d.ts", + "bytes": 1021, + "sha256": "6c55edebbf07b87952ce9fa65ae61aa35e6ec5360b5c82eb46fe468755e3c6fe" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/adapter.js", + "bytes": 5078, + "sha256": "7dc0b972037f817fa3b8e3ec983646f0d3b50f06bc075e9f0c80d336a2d60142" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/adapter.js.map", + "bytes": 4236, + "sha256": "58d319f6384c4158b0cbc0c97db68130abc22a0d14f55ee22447c0351b90bb57" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/constants.d.ts", + "bytes": 1319, + "sha256": "e42eaf42e4a1aa6876990d1c8d383692e6ea473c5bb93d94daf3be539a46e768" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/constants.js", + "bytes": 1743, + "sha256": "b17dcda45c891b25e1eb382151048e37d220e25fa8fd2a208ee9a81fb0e6b22f" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/constants.js.map", + "bytes": 1578, + "sha256": "8b9720613fb8edbc34f78287a4f5645cf0bab5575e93c9eb20b600f14757a2b6" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/control.d.ts", + "bytes": 3110, + "sha256": "8f9ad2937ecfa76333da5e27df5a15f40b03f2e9b7140ca7d45fe5f41123337e" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/control.js", + "bytes": 7524, + "sha256": "0d2d46b93174b841f0ca69df212d97c781b0045004834805b4e1393a62427dc6" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/control.js.map", + "bytes": 5786, + "sha256": "5745603c2460d596a390e16e9a0687d4cac8366d92d67a3f0511d0303eff9aaa" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/device.d.ts", + "bytes": 690, + "sha256": "1d7d9a195a14d9be22319c800e22fb13a67402f74c7662612e2e26a8bb80844c" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/device.js", + "bytes": 1914, + "sha256": "8abf7d414d26ba21b38709b9016a6c5fb89e0ecb0908b01a9bf3e497dd99e731" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/device.js.map", + "bytes": 1684, + "sha256": "0eaf2239c4b19bfac8aaed54080231716cbb9440207a9bc3086865c2d02d0ae5" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/h264.d.ts", + "bytes": 400, + "sha256": "6d3ca9b66d344a200f7f1d50801cfe75506f0f47bb25b7d3b632523b1fdc8459" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/h264.js", + "bytes": 2213, + "sha256": "918ba61e5ac1c7f186102e8fae149d0855b171bd1f1d126df27eee027492a43d" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/h264.js.map", + "bytes": 2617, + "sha256": "04bc2c0a25445c7fad35fad73e091ebe91d669bd185f465041c03c6acb512d65" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/handshake.d.ts", + "bytes": 291, + "sha256": "e0b28c022282702b68fe05f6122032e24440825857afc057dabd66886ffd360b" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/handshake.js", + "bytes": 689, + "sha256": "dfc2d0038117c8821715fcc442abc93dcd2b117cba862b000fa988b90c254ace" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/handshake.js.map", + "bytes": 834, + "sha256": "5c39d2d343d6d72f8895819527f0de54265e91ccaebc749049255643d263a556" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/index.d.ts", + "bytes": 208, + "sha256": "83bb34a9a93cc19775845e021cb04ebbab146486f8e55a9e69e0b1a80a0ddf9b" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/index.js", + "bytes": 241, + "sha256": "9470acdc6865f9237ad6c1385d21a2a9ad2752ecd62bdd137e0dd296cb3ff252" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/index.js.map", + "bytes": 274, + "sha256": "18572ffc879b1367e1584db05f41e3f3940a816d06089d132a482cb1f3d3990b" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/media.d.ts", + "bytes": 836, + "sha256": "7cbb9223f6fcfa665361f3d15450774baa920667fc93a21ff42cd8cca91b1748" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/media.js", + "bytes": 4595, + "sha256": "21aae13478490fcc5311f4442fc5bcb4e5a820c263d35b0b853948970f8772f0" + }, + { + "path": "vendor/scrcpy-protocol/src/versions/v4_1/media.js.map", + "bytes": 4275, + "sha256": "a7b8922f316ec12b1729f53fa5bf900fae02839a4a9a27de40549e9341a584c7" + } + ] +} diff --git a/apps/web-client/dist/assets/controller.js b/apps/web-client/dist/assets/controller.js index 15525a7..733ae2d 100644 --- a/apps/web-client/dist/assets/controller.js +++ b/apps/web-client/dist/assets/controller.js @@ -6,6 +6,16 @@ import { WebCodecsVideoRenderer } from "./video-renderer.js"; import { WebSocketBridgeTransport } from "./websocket-transport.js"; import { WebCodecsAudioPlayer } from "./audio-player.js"; const DEVICE_DROPDOWN_REFRESH_STALE_MS = 1500; +// textInjectionMessages chunks at 300 UTF-8 bytes and sendMessages awaits every +// chunk, so injection cost grows linearly with the text: ~875 sequential round +// trips at the 256 KiB clipboard limit. Above this size the clipboard is still +// synchronized and the user pastes on the device. +const MAX_INJECTED_BYTES = 8 * 1024; +// An unacknowledged automatic sync is deliberately retryable, but the poller +// runs every 1800ms and only skips text it has recorded as sent, so without a +// cap the same text is re-sent every tick for as long as it stays on the PC +// clipboard. Give up after this many attempts and stop asking. +const MAX_UNACKNOWLEDGED_SYNC_ATTEMPTS = 3; export class DroidWebDisplayController { elements; #api = new BridgeApi(); @@ -26,6 +36,7 @@ export class DroidWebDisplayController { #lastConnectValues = null; #lastAndroidClipboard = ""; #lastSentClipboard = ""; + #unacknowledgedSync = null; #clipboardPollTimer = null; #clipboardReadAllowed = false; #clipboardPollBusy = false; @@ -573,8 +584,12 @@ export class DroidWebDisplayController { if (!text) throw new Error("Enter or paste text into the fallback box first"); const maximum = Math.max(1, Math.min(256, Number(this.elements.clipboardMaxKib.value) || 256)) * 1024; - if (new TextEncoder().encode(text).byteLength > maximum) + const bytes = new TextEncoder().encode(text).byteLength; + if (bytes > maximum) throw new Error(`Text exceeds the configured ${maximum / 1024} KiB limit`); + if (bytes > MAX_INJECTED_BYTES) { + throw new Error(`Text is too large to type into Android (${Math.ceil(bytes / 1024)} KiB). Use Paste, which synchronizes the clipboard instead of typing.`); + } this.setStatus("Typing", "Injecting the text box directly into the focused Android input field…"); await this.sendMessages(textInjectionMessages(text)); this.setStatus("Text typed", "Text box content was injected directly into Android without using the clipboard."); @@ -582,12 +597,16 @@ export class DroidWebDisplayController { async pasteText(text, source) { const session = this.#protocolSession; const maximum = Math.max(1, Math.min(256, Number(this.elements.clipboardMaxKib.value) || 256)) * 1024; - if (new TextEncoder().encode(text).byteLength > maximum) + const bytes = new TextEncoder().encode(text).byteLength; + if (bytes > maximum) throw new Error(`Clipboard text exceeds the configured ${maximum / 1024} KiB limit`); if (!session) return; + const inject = bytes <= MAX_INJECTED_BYTES; const sequence = this.#clipboardSequence++; - this.setStatus("Pasting", `Synchronizing ${source} and injecting it into the focused Android input field…`); + this.setStatus("Pasting", inject + ? `Synchronizing ${source} and injecting it into the focused Android input field…` + : `Synchronizing ${source} with the Android clipboard…`); const acknowledgement = this.waitForClipboardAcknowledgement(sequence); try { // SetClipboard(paste=true) only proves that Android processed the clipboard @@ -595,13 +614,17 @@ export class DroidWebDisplayController { // clipboard synchronized with paste=false, then use scrcpy InjectText as the // deterministic insertion path (the same strategy as scrcpy legacy paste). await session.sendControl(clipboardMessage(text, sequence, false)); - await this.sendMessages(textInjectionMessages(text)); + if (inject) await this.sendMessages(textInjectionMessages(text)); if (await acknowledgement) { this.#lastSentClipboard = text; - this.setStatus("Text pasted", `${source} was injected directly and the Android clipboard synchronization was acknowledged.`); + this.setStatus(inject ? "Text pasted" : "Clipboard synchronized", inject + ? `${source} was injected directly and the Android clipboard synchronization was acknowledged.` + : `${source} is on the Android clipboard. It is too large to type, so paste it on the device.`); } else { - this.setStatus("Text sent", `${source} was injected directly, but Android clipboard synchronization was not acknowledged.`); + this.setStatus("Text sent", inject + ? `${source} was injected directly, but Android clipboard synchronization was not acknowledged.` + : `${source} was sent to the Android clipboard but not acknowledged. If it arrived, paste it on the device.`); } } catch (error) { @@ -811,9 +834,12 @@ export class DroidWebDisplayController { return pending; } resetClipboardSessionState() { + // Cached device clipboard state must not leak across sessions. The visible + // text box is user input, not cached state, so it is deliberately left + // alone: clearing it discards text typed while disconnected. this.#lastAndroidClipboard = ""; this.#lastSentClipboard = ""; - this.elements.clipboardText.value = ""; + this.#unacknowledgedSync = null; this.completeAndroidCopyRequest(); } async startClipboardPolling(requestPermission) { @@ -918,10 +944,20 @@ export class DroidWebDisplayController { await session.sendControl(clipboardMessage(text, sequence, false)); if (await acknowledgement) { this.#lastSentClipboard = text; + this.#unacknowledgedSync = null; this.setStatus("Clipboard synchronized", "PC clipboard was acknowledged by Android without pasting into the focused field."); } else { - this.setStatus("Clipboard sync not confirmed", "PC clipboard update was sent, but Android did not acknowledge it."); + const attempts = this.#unacknowledgedSync?.text === text ? this.#unacknowledgedSync.attempts + 1 : 1; + this.#unacknowledgedSync = { text, attempts }; + if (attempts >= MAX_UNACKNOWLEDGED_SYNC_ATTEMPTS) { + // Record it as sent so the poller stops retrying this text. + this.#lastSentClipboard = text; + this.setStatus("Clipboard sync gave up", `Android did not acknowledge the PC clipboard after ${attempts} attempts. Copy again or use Paste to retry.`); + } + else { + this.setStatus("Clipboard sync not confirmed", "PC clipboard update was sent, but Android did not acknowledge it. It will be retried."); + } } } catch (error) { diff --git a/apps/web-client/src/controller.ts b/apps/web-client/src/controller.ts index 3cd61cd..6b2aa94 100644 --- a/apps/web-client/src/controller.ts +++ b/apps/web-client/src/controller.ts @@ -20,6 +20,16 @@ import { WebSocketBridgeTransport } from "./websocket-transport.js"; import { WebCodecsAudioPlayer, type AudioStatistics } from "./audio-player.js"; const DEVICE_DROPDOWN_REFRESH_STALE_MS = 1500; +// textInjectionMessages chunks at 300 UTF-8 bytes and sendMessages awaits every +// chunk, so injection cost grows linearly with the text: ~875 sequential round +// trips at the 256 KiB clipboard limit. Above this size the clipboard is still +// synchronized and the user pastes on the device. +const MAX_INJECTED_BYTES = 8 * 1024; +// An unacknowledged automatic sync is deliberately retryable, but the poller +// runs every 1800ms and only skips text it has recorded as sent, so without a +// cap the same text is re-sent every tick for as long as it stays on the PC +// clipboard. Give up after this many attempts and stop asking. +const MAX_UNACKNOWLEDGED_SYNC_ATTEMPTS = 3; interface ClipboardAckWaiter { readonly resolve: (acknowledged: boolean) => void; @@ -101,6 +111,7 @@ export class DroidWebDisplayController { #lastConnectValues: DisplayFormValues | null = null; #lastAndroidClipboard = ""; #lastSentClipboard = ""; + #unacknowledgedSync: { readonly text: string; readonly attempts: number } | null = null; #clipboardPollTimer: number | null = null; #clipboardReadAllowed = false; #clipboardPollBusy = false; @@ -631,7 +642,11 @@ export class DroidWebDisplayController { const text = this.elements.clipboardText.value; if (!text) throw new Error("Enter or paste text into the fallback box first"); const maximum = Math.max(1, Math.min(256, Number(this.elements.clipboardMaxKib.value) || 256)) * 1024; - if (new TextEncoder().encode(text).byteLength > maximum) throw new Error(`Text exceeds the configured ${maximum / 1024} KiB limit`); + const bytes = new TextEncoder().encode(text).byteLength; + if (bytes > maximum) throw new Error(`Text exceeds the configured ${maximum / 1024} KiB limit`); + if (bytes > MAX_INJECTED_BYTES) { + throw new Error(`Text is too large to type into Android (${Math.ceil(bytes / 1024)} KiB). Use Paste, which synchronizes the clipboard instead of typing.`); + } this.setStatus("Typing", "Injecting the text box directly into the focused Android input field…"); await this.sendMessages(textInjectionMessages(text)); this.setStatus("Text typed", "Text box content was injected directly into Android without using the clipboard."); @@ -640,10 +655,14 @@ export class DroidWebDisplayController { private async pasteText(text: string, source: string): Promise { const session = this.#protocolSession; const maximum = Math.max(1, Math.min(256, Number(this.elements.clipboardMaxKib.value) || 256)) * 1024; - if (new TextEncoder().encode(text).byteLength > maximum) throw new Error(`Clipboard text exceeds the configured ${maximum / 1024} KiB limit`); + const bytes = new TextEncoder().encode(text).byteLength; + if (bytes > maximum) throw new Error(`Clipboard text exceeds the configured ${maximum / 1024} KiB limit`); if (!session) return; + const inject = bytes <= MAX_INJECTED_BYTES; const sequence = this.#clipboardSequence++; - this.setStatus("Pasting", `Synchronizing ${source} and injecting it into the focused Android input field…`); + this.setStatus("Pasting", inject + ? `Synchronizing ${source} and injecting it into the focused Android input field…` + : `Synchronizing ${source} with the Android clipboard…`); const acknowledgement = this.waitForClipboardAcknowledgement(sequence); try { // SetClipboard(paste=true) only proves that Android processed the clipboard @@ -651,12 +670,16 @@ export class DroidWebDisplayController { // clipboard synchronized with paste=false, then use scrcpy InjectText as the // deterministic insertion path (the same strategy as scrcpy legacy paste). await session.sendControl(clipboardMessage(text, sequence, false)); - await this.sendMessages(textInjectionMessages(text)); + if (inject) await this.sendMessages(textInjectionMessages(text)); if (await acknowledgement) { this.#lastSentClipboard = text; - this.setStatus("Text pasted", `${source} was injected directly and the Android clipboard synchronization was acknowledged.`); + this.setStatus(inject ? "Text pasted" : "Clipboard synchronized", inject + ? `${source} was injected directly and the Android clipboard synchronization was acknowledged.` + : `${source} is on the Android clipboard. It is too large to type, so paste it on the device.`); } else { - this.setStatus("Text sent", `${source} was injected directly, but Android clipboard synchronization was not acknowledged.`); + this.setStatus("Text sent", inject + ? `${source} was injected directly, but Android clipboard synchronization was not acknowledged.` + : `${source} was sent to the Android clipboard but not acknowledged. If it arrived, paste it on the device.`); } } catch (error) { this.resolveClipboardAcknowledgement(sequence, false); @@ -864,9 +887,12 @@ export class DroidWebDisplayController { } private resetClipboardSessionState(): void { + // Cached device clipboard state must not leak across sessions. The visible + // text box is user input, not cached state, so it is deliberately left + // alone: clearing it discards text typed while disconnected. this.#lastAndroidClipboard = ""; this.#lastSentClipboard = ""; - this.elements.clipboardText.value = ""; + this.#unacknowledgedSync = null; this.completeAndroidCopyRequest(); } @@ -971,9 +997,18 @@ export class DroidWebDisplayController { await session.sendControl(clipboardMessage(text, sequence, false)); if (await acknowledgement) { this.#lastSentClipboard = text; + this.#unacknowledgedSync = null; this.setStatus("Clipboard synchronized", "PC clipboard was acknowledged by Android without pasting into the focused field."); } else { - this.setStatus("Clipboard sync not confirmed", "PC clipboard update was sent, but Android did not acknowledge it."); + const attempts = this.#unacknowledgedSync?.text === text ? this.#unacknowledgedSync.attempts + 1 : 1; + this.#unacknowledgedSync = { text, attempts }; + if (attempts >= MAX_UNACKNOWLEDGED_SYNC_ATTEMPTS) { + // Record it as sent so the poller stops retrying this text. + this.#lastSentClipboard = text; + this.setStatus("Clipboard sync gave up", `Android did not acknowledge the PC clipboard after ${attempts} attempts. Copy again or use Paste to retry.`); + } else { + this.setStatus("Clipboard sync not confirmed", "PC clipboard update was sent, but Android did not acknowledge it. It will be retried."); + } } } catch (error) { this.resolveClipboardAcknowledgement(sequence, false); diff --git a/droid_web_display/adb/client.py b/droid_web_display/adb/client.py index 5046387..01b6f30 100644 --- a/droid_web_display/adb/client.py +++ b/droid_web_display/adb/client.py @@ -4,11 +4,11 @@ import os import re import shutil -import subprocess from dataclasses import dataclass from pathlib import Path from typing import Mapping, Protocol, Sequence +from droid_web_display.process_utils import subprocess_creation_kwargs from droid_web_display.errors import AdbCommandError, AdbUnavailableError from droid_web_display.models import AndroidDevice @@ -34,11 +34,7 @@ async def _terminate(process: asyncio.subprocess.Process) -> None: def _subprocess_creation_kwargs(platform_name: str | None = None) -> dict[str, int]: """Return platform-specific flags for invisible background child processes.""" - if (platform_name or os.name) != "nt": - return {} - # CREATE_NO_WINDOW is 0x08000000. Keep the literal fallback so tests and - # alternate Python runtimes can still validate the Windows launch contract. - return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)} + return subprocess_creation_kwargs(platform_name) @dataclass(frozen=True) diff --git a/droid_web_display/desktop/controller.py b/droid_web_display/desktop/controller.py index d7d46d5..3f63eb5 100644 --- a/droid_web_display/desktop/controller.py +++ b/droid_web_display/desktop/controller.py @@ -16,6 +16,7 @@ import psutil +from droid_web_display.process_utils import subprocess_creation_kwargs from droid_web_display.adb.devices import parse_adb_devices from droid_web_display.network_access import LAN_HTTPS, NetworkConfigStore @@ -250,8 +251,7 @@ def _device_summary(self) -> str: "timeout": 1.25, "check": False, } - if os.name == "nt": - kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0) + kwargs.update(subprocess_creation_kwargs()) try: result = subprocess.run(command, **kwargs) except (OSError, subprocess.SubprocessError): diff --git a/droid_web_display/desktop/support.py b/droid_web_display/desktop/support.py index a45df9e..292a040 100644 --- a/droid_web_display/desktop/support.py +++ b/droid_web_display/desktop/support.py @@ -11,6 +11,7 @@ import zipfile from droid_web_display import __version__ +from droid_web_display.process_utils import subprocess_creation_kwargs from droid_web_display.desktop.controller import DesktopPaths, ServerSnapshot from droid_web_display.diagnostics import redact_text @@ -125,8 +126,7 @@ def _run_version(command: list[str]) -> str: "timeout": 2.0, "check": False, } - if os.name == "nt": - kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0) + kwargs.update(subprocess_creation_kwargs()) try: result = subprocess.run(command, **kwargs) except (OSError, subprocess.SubprocessError): diff --git a/droid_web_display/network_access.py b/droid_web_display/network_access.py index 4bc100e..7c94ab2 100644 --- a/droid_web_display/network_access.py +++ b/droid_web_display/network_access.py @@ -9,6 +9,8 @@ import socket import ssl import subprocess + +from droid_web_display.process_utils import subprocess_creation_kwargs import tempfile from typing import Any, Iterable @@ -449,7 +451,16 @@ def apply(self, config: NetworkAccessConfig, *, remove: bool = False) -> dict[st if os.name != "nt": return {"applied": False, "reason": "Windows-only", "command": argv} try: - result = subprocess.run(argv, capture_output=True, text=True, timeout=30, check=False) + # Windows-only path: without the creation flags this pops a visible + # PowerShell window every time LAN access is applied or removed. + result = subprocess.run( + argv, + capture_output=True, + text=True, + timeout=30, + check=False, + **subprocess_creation_kwargs(), + ) except OSError as exc: return {"applied": False, "reason": str(exc), "command": argv} return { diff --git a/droid_web_display/process_utils.py b/droid_web_display/process_utils.py new file mode 100644 index 0000000..88a09d5 --- /dev/null +++ b/droid_web_display/process_utils.py @@ -0,0 +1,25 @@ +"""Shared subprocess launch policy. + +Every child process this app spawns is a background helper the user never +interacts with. On Windows each one would otherwise pop a console window, so +the CREATE_NO_WINDOW flag has to be applied consistently. It previously lived +inline in three modules with two different fallbacks, and the firewall call +had none at all, so it is centralised here. +""" + +from __future__ import annotations + +import os +import subprocess + +# subprocess.CREATE_NO_WINDOW exists on Windows CPython 3.7+. The literal +# keeps the contract testable from non-Windows runtimes, where the attribute +# is absent. +CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) + + +def subprocess_creation_kwargs(platform_name: str | None = None) -> dict[str, int]: + """Return platform-specific flags for invisible background child processes.""" + if (platform_name or os.name) != "nt": + return {} + return {"creationflags": CREATE_NO_WINDOW} diff --git a/tests/packaging/test_clipboard_sync_regression.py b/tests/packaging/test_clipboard_sync_regression.py index 67f5a89..6b3f4b4 100644 --- a/tests/packaging/test_clipboard_sync_regression.py +++ b/tests/packaging/test_clipboard_sync_regression.py @@ -94,8 +94,11 @@ def test_clipboard_session_state_is_reset_before_use_and_on_cleanup() -> None: for reset in (source[src_reset_start:src_reset_end], dist[built_reset_start:built_reset_end]): assert '#lastAndroidClipboard = ""' in reset assert '#lastSentClipboard = ""' in reset - assert 'clipboardText.value = ""' in reset + assert "#unacknowledgedSync = null" in reset assert "completeAndroidCopyRequest()" in reset + # The visible text box is user input, not cached device state. Clearing + # it on connect discards text typed while disconnected. + assert 'clipboardText.value = ""' not in reset def test_failed_pc_clipboard_sync_remains_retryable() -> None: @@ -109,6 +112,13 @@ def test_failed_pc_clipboard_sync_remains_retryable() -> None: assert "clipboardMessage(text, sequence, false)" in block assert "pasteText(text" not in block assert "clipboardMessage(text, sequence, true)" not in block + # Retryable, but bounded: pollPcClipboard runs every 1800ms and skips + # only text recorded as sent, so an unbounded retry re-sends the same + # text every tick for as long as it stays on the PC clipboard. + assert "MAX_UNACKNOWLEDGED_SYNC_ATTEMPTS" in block + assert "#unacknowledgedSync" in block + gave_up = block.index("Clipboard sync gave up") + assert block.index("this.#lastSentClipboard = text", gave_up - 400) < gave_up def test_clipboard_permission_prompt_keeps_user_activation_and_drawer_does_not_force_textarea_focus() -> None: @@ -123,3 +133,21 @@ def test_clipboard_permission_prompt_keeps_user_activation_and_drawer_does_not_f assert "this.elements.clipboardText.focus()" not in source assert "this.elements.clipboardText.focus()" not in dist + + +def test_oversized_text_is_synchronized_rather_than_typed_chunk_by_chunk() -> None: + source, dist = _controller_blocks() + src = source[source.index("\n private async pasteText"):source.index("\n private waitForClipboardAcknowledgement")] + built = dist[dist.index("\n async pasteText"):dist.index("\n waitForClipboardAcknowledgement")] + for block in (src, built): + # textInjectionMessages chunks at 300 UTF-8 bytes and each chunk is an + # awaited control message, so injecting a 256 KiB clipboard would be + # roughly 875 sequential round trips. + assert "MAX_INJECTED_BYTES" in block + assert "if (inject) await this.sendMessages(textInjectionMessages(text))" in block + + src_type = source[source.index("\n private async pasteTypedText"):source.index("\n private async pasteText")] + built_type = dist[dist.index("\n async pasteTypedText"):dist.index("\n async pasteText")] + for block in (src_type, built_type): + assert "MAX_INJECTED_BYTES" in block + assert "too large to type into Android" in block From 3ecf3cf1e338c7eda27f6341ee883fd7b7dd1225 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:40:48 +0300 Subject: [PATCH 2/2] Match the compiler emit style for the bounded injection branch CI rebuilds dist/ with tsc, which splits a single-statement `if` across two lines. The hand-applied dist used a one-liner, so the new oversized-text test failed against a real build even though it passed against the hand-applied file. dist now uses the compiler's form, and the assertion matches either style so a future rebuild cannot fail on whitespace alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfT5Z3Z24bjC8qtqERGpMQ --- apps/web-client/dist-manifest.json | 4 ++-- apps/web-client/dist/assets/controller.js | 3 ++- tests/packaging/test_clipboard_sync_regression.py | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web-client/dist-manifest.json b/apps/web-client/dist-manifest.json index d2dc3ad..967d38c 100644 --- a/apps/web-client/dist-manifest.json +++ b/apps/web-client/dist-manifest.json @@ -85,8 +85,8 @@ }, { "path": "assets/controller.js", - "bytes": 57105, - "sha256": "4692db581e43f0b65341481b3a7f6a98ca3ad700c753bd4157a1a5ac7ba2c3fc" + "bytes": 57121, + "sha256": "35577dbe1abb009a81530d785c6243c7f79947c8b5d0bc3b81d4c0f3cc7ee230" }, { "path": "assets/controller.js.map", diff --git a/apps/web-client/dist/assets/controller.js b/apps/web-client/dist/assets/controller.js index 733ae2d..74652e6 100644 --- a/apps/web-client/dist/assets/controller.js +++ b/apps/web-client/dist/assets/controller.js @@ -614,7 +614,8 @@ export class DroidWebDisplayController { // clipboard synchronized with paste=false, then use scrcpy InjectText as the // deterministic insertion path (the same strategy as scrcpy legacy paste). await session.sendControl(clipboardMessage(text, sequence, false)); - if (inject) await this.sendMessages(textInjectionMessages(text)); + if (inject) + await this.sendMessages(textInjectionMessages(text)); if (await acknowledgement) { this.#lastSentClipboard = text; this.setStatus(inject ? "Text pasted" : "Clipboard synchronized", inject diff --git a/tests/packaging/test_clipboard_sync_regression.py b/tests/packaging/test_clipboard_sync_regression.py index 6b3f4b4..a524e9c 100644 --- a/tests/packaging/test_clipboard_sync_regression.py +++ b/tests/packaging/test_clipboard_sync_regression.py @@ -1,3 +1,4 @@ +import re from pathlib import Path @@ -144,7 +145,7 @@ def test_oversized_text_is_synchronized_rather_than_typed_chunk_by_chunk() -> No # awaited control message, so injecting a 256 KiB clipboard would be # roughly 875 sequential round trips. assert "MAX_INJECTED_BYTES" in block - assert "if (inject) await this.sendMessages(textInjectionMessages(text))" in block + assert re.search(r"if \(inject\)\s+await this\.sendMessages\(textInjectionMessages\(text\)\)", block) src_type = source[source.index("\n private async pasteTypedText"):source.index("\n private async pasteText")] built_type = dist[dist.index("\n async pasteTypedText"):dist.index("\n async pasteText")]