Compare commits
63 Commits
main
...
feature/an
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06bb7bcd1c | ||
|
|
3b219a442d | ||
|
|
45daba5419 | ||
|
|
efcaeaad26 | ||
|
|
9a6f6962d2 | ||
|
|
4379b01d41 | ||
|
|
8eed1e20e1 | ||
|
|
ad46fc805b | ||
|
|
b1ac32175c | ||
|
|
98b4f03cff | ||
|
|
e96502f888 | ||
|
|
cff7fc8ab3 | ||
|
|
1a6c3f15a2 | ||
|
|
aede7b6c3c | ||
|
|
7c3b6b3e93 | ||
|
|
3773a0851c | ||
|
|
2022bbcca0 | ||
|
|
78ac9d9972 | ||
|
|
39af42e1b3 | ||
|
|
114e8431a6 | ||
|
|
3891c970eb | ||
|
|
bb3cf6b09b | ||
|
|
718b66617f | ||
|
|
f28db050fb | ||
|
|
4eb82f9779 | ||
|
|
99ffffa393 | ||
|
|
8993a38658 | ||
|
|
9792662181 | ||
|
|
0089ae86e1 | ||
|
|
9f0b0742a8 | ||
|
|
a93430dd3f | ||
|
|
79e3e77c97 | ||
|
|
8ffe19cd5f | ||
|
|
936986a940 | ||
|
|
6c28a3f7e3 | ||
|
|
55cc8edbc1 | ||
|
|
174a3f8ac0 | ||
|
|
5075888861 | ||
|
|
38df106fb6 | ||
|
|
5079d33b0a | ||
|
|
fe558a1c1e | ||
|
|
6a6f989595 | ||
|
|
5047618c01 | ||
|
|
f119300a57 | ||
|
|
58bb66f4c2 | ||
|
|
64d497e577 | ||
|
|
44767ed370 | ||
|
|
34df7b070b | ||
|
|
1837658c85 | ||
|
|
6e83bda8b8 | ||
|
|
8410a8de2a | ||
|
|
192513dd2a | ||
|
|
e778ff73cc | ||
|
|
e814cb64e7 | ||
|
|
9e50bc6237 | ||
|
|
703eb64fc0 | ||
|
|
4533968057 | ||
|
|
18d12baba1 | ||
|
|
2487e616c2 | ||
|
|
d659a4e7f1 | ||
|
|
bb392507bd | ||
|
|
bae871c78c | ||
|
|
49f85db9f9 |
173
Cargo.lock
generated
173
Cargo.lock
generated
@ -31,6 +31,17 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android-ffi"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"common",
|
||||||
|
"jni 0.21.1",
|
||||||
|
"tokio",
|
||||||
|
"viewer-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
@ -76,7 +87,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"thiserror",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -121,6 +132,12 @@ dependencies = [
|
|||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cesu8"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@ -387,6 +404,22 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.21.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||||
|
dependencies = [
|
||||||
|
"cesu8",
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-sys 0.3.1",
|
||||||
|
"log",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"walkdir",
|
||||||
|
"windows-sys 0.45.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.22.4"
|
version = "0.22.4"
|
||||||
@ -396,10 +429,10 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"combine",
|
"combine",
|
||||||
"jni-macros",
|
"jni-macros",
|
||||||
"jni-sys",
|
"jni-sys 0.4.1",
|
||||||
"log",
|
"log",
|
||||||
"simd_cesu8",
|
"simd_cesu8",
|
||||||
"thiserror",
|
"thiserror 2.0.21",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@ -417,6 +450,15 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@ -628,8 +670,6 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nvenc"
|
name = "nvenc"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3227c3b09b1e042dab7d8d82cea77c0404e7d0c2bbb92e7e87cb319749cd20dc"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitfields",
|
"bitfields",
|
||||||
"libloading",
|
"libloading",
|
||||||
@ -766,7 +806,7 @@ dependencies = [
|
|||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror",
|
"thiserror 2.0.21",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@ -790,7 +830,7 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
"slab",
|
"slab",
|
||||||
"thiserror",
|
"thiserror 2.0.21",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@ -975,7 +1015,7 @@ checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation",
|
"core-foundation",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustls",
|
"rustls",
|
||||||
@ -1233,13 +1273,33 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.21"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1364,6 +1424,19 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "viewer-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"common",
|
||||||
|
"openh264",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@ -1680,13 +1753,22 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.45.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.42.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1698,20 +1780,35 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.42.2",
|
||||||
|
"windows_aarch64_msvc 0.42.2",
|
||||||
|
"windows_i686_gnu 0.42.2",
|
||||||
|
"windows_i686_msvc 0.42.2",
|
||||||
|
"windows_x86_64_gnu 0.42.2",
|
||||||
|
"windows_x86_64_gnullvm 0.42.2",
|
||||||
|
"windows_x86_64_msvc 0.42.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu",
|
"windows_i686_gnu 0.52.6",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm",
|
||||||
"windows_i686_msvc",
|
"windows_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc",
|
"windows_x86_64_msvc 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1723,18 +1820,36 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@ -1747,24 +1862,48 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
# agent-core:平台无关会话层 + 软件编码视频管线(当前仅 linux-agent 使用)
|
# agent-core:平台无关会话层 + 软件编码视频管线(当前仅 linux-agent 使用)
|
||||||
# linux-agent:Linux 被控端(X11 + XTest)
|
# linux-agent:Linux 被控端(X11 + XTest)
|
||||||
members = ["common", "agent-core", "agent", "linux-agent", "viewer"]
|
# viewer-core:安卓主控的连接/协议/收帧层(桌面 headless 可验证;Android 经 JNI 复用)
|
||||||
|
# android-ffi:viewer-core 的 JNI 薄层(cdylib,交叉编译成 libandroidffi.so)
|
||||||
|
members = ["common", "agent-core", "agent", "linux-agent", "viewer", "viewer-core", "android-ffi"]
|
||||||
# 默认成员保持 Windows 三件套,`cargo build --release` 行为与改动前完全一致;
|
# 默认成员保持 Windows 三件套,`cargo build --release` 行为与改动前完全一致;
|
||||||
# Linux 被控端显式构建:`cargo build --release -p linux-agent`
|
# Linux 被控端显式构建:`cargo build --release -p linux-agent`
|
||||||
default-members = ["common", "agent", "viewer"]
|
default-members = ["common", "agent", "viewer"]
|
||||||
|
|||||||
@ -27,4 +27,4 @@ agent.exe --port 47800 --code 123456
|
|||||||
viewer.exe --addr <被控机IP> --port 47800 --code 123456
|
viewer.exe --addr <被控机IP> --port 47800 --code 123456
|
||||||
```
|
```
|
||||||
|
|
||||||
详见《构建运行手册.md》与《Git协作规范.md》。
|
详见《构建运行手册.md》、《Git协作规范.md》与《打洞与信令设计.md》。
|
||||||
|
|||||||
@ -11,8 +11,8 @@
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::{
|
use common::{
|
||||||
encode_video_frame, read_control, write_control, ControlMsg, InputEvent, MouseButton,
|
encode_video_frame, h264_contains_idr, read_control, write_control, ControlMsg, InputEvent,
|
||||||
VideoFrameHeader,
|
MouseButton, VideoFrameHeader,
|
||||||
};
|
};
|
||||||
use openh264::encoder::{
|
use openh264::encoder::{
|
||||||
BitRate, Encoder, EncoderConfig, FrameRate, IntraFramePeriod, RateControlMode, UsageType,
|
BitRate, Encoder, EncoderConfig, FrameRate, IntraFramePeriod, RateControlMode, UsageType,
|
||||||
@ -40,6 +40,22 @@ pub trait ScreenCapturer: Send {
|
|||||||
|
|
||||||
/// 取出最近一帧,BGRA 紧凑排列(已去掉 row pitch),供 openh264 直接编码。
|
/// 取出最近一帧,BGRA 紧凑排列(已去掉 row pitch),供 openh264 直接编码。
|
||||||
fn copy_frame(&mut self) -> Result<Vec<u8>>;
|
fn copy_frame(&mut self) -> Result<Vec<u8>>;
|
||||||
|
|
||||||
|
/// 协议 v5 的光标采样:当前指针在**采集画面内**的物理像素坐标。
|
||||||
|
/// 语义与折叠规则(隐藏/离屏 → None)见 `common::VideoFrameHeader::cursor`。
|
||||||
|
///
|
||||||
|
/// **默认实现刻意返回 None,但必须留下可见痕迹**:Linux 后端本轮未实现光标采样
|
||||||
|
/// (X11 `query_pointer` 与 Windows `GetCursorInfo` 不同构,且本机无 X11 无法给
|
||||||
|
/// proof——4b/Q4=A 裁决另立循环)。全局规则要求异常路径有可见后果——安卓用户对着
|
||||||
|
/// Linux 被控端看不到标记不能是静默的,日志必须说明是没实现。一次性日志防刷屏
|
||||||
|
/// (本方法每帧都会被调,最高 60Hz)。
|
||||||
|
fn pointer_state(&self) -> Option<(i16, i16)> {
|
||||||
|
static LOGGED_UNIMPL: AtomicBool = AtomicBool::new(false);
|
||||||
|
if !LOGGED_UNIMPL.swap(true, Ordering::Relaxed) {
|
||||||
|
eprintln!("[agent-core] 光标采样后端未实现:帧头 cursor 将恒为 None");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 输入注入后端。实现需可在异步运行时中被多任务共享,故要求 `Send + Sync`。
|
/// 输入注入后端。实现需可在异步运行时中被多任务共享,故要求 `Send + Sync`。
|
||||||
@ -48,6 +64,31 @@ pub trait InputInjector: Send + Sync + 'static {
|
|||||||
fn mouse_button(&self, b: MouseButton, down: bool);
|
fn mouse_button(&self, b: MouseButton, down: bool);
|
||||||
fn wheel(&self, dy: i32);
|
fn wheel(&self, dy: i32);
|
||||||
fn key(&self, scancode: u16, extended: bool, down: bool);
|
fn key(&self, scancode: u16, extended: bool, down: bool);
|
||||||
|
|
||||||
|
/// 协议 v3 的文本注入(UTF-16 code unit)。
|
||||||
|
///
|
||||||
|
/// **默认实现刻意不注入任何东西,但必须留下可见痕迹**:Linux 后端本轮未实现
|
||||||
|
/// 文本注入(X11 需要 keysym/XKB 映射,与 Windows 的 `KEYEVENTF_UNICODE`
|
||||||
|
/// 不同构,另立循环)。全局规则要求异常路径有可见后果——用户对着 Linux 被控端
|
||||||
|
/// 打字时「什么都没发生」不能是静默的,日志必须说明是没实现。
|
||||||
|
///
|
||||||
|
/// 日志纪律:只打印长度,**不打印用户输入内容**。
|
||||||
|
fn text(&self, units: &[u16]) {
|
||||||
|
eprintln!(
|
||||||
|
"[agent-core] 文本注入未实现(忽略 {} 个 UTF-16 code unit)",
|
||||||
|
units.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 协议 v4 的相对鼠标移动(单位:被控端桌面物理像素)。
|
||||||
|
///
|
||||||
|
/// 与 [`InputInjector::text`] 同一条纪律:**默认实现不注入,但必须留下可见痕迹**。
|
||||||
|
/// Linux/X11 后端本轮未实现相对移动(XTest 的相对语义要自己维护指针状态,
|
||||||
|
/// 与 Windows 的 `GetCursorPos`+绝对注入不同构,另立循环)。用户用相对模式
|
||||||
|
/// 操作 Linux 被控端时「指针纹丝不动」不能是静默的,日志必须说明是没实现。
|
||||||
|
fn mouse_move_rel(&self, dx: i16, dy: i16) {
|
||||||
|
eprintln!("[agent-core] 相对鼠标注入未实现(忽略 dx={dx} dy={dy})");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把一条协议输入事件分发到注入后端。
|
/// 把一条协议输入事件分发到注入后端。
|
||||||
@ -59,6 +100,20 @@ pub fn dispatch_input(inj: &dyn InputInjector, ev: InputEvent) {
|
|||||||
InputEvent::Wheel { dy } => inj.wheel(dy),
|
InputEvent::Wheel { dy } => inj.wheel(dy),
|
||||||
InputEvent::KeyDown { scancode, extended } => inj.key(scancode, extended, true),
|
InputEvent::KeyDown { scancode, extended } => inj.key(scancode, extended, true),
|
||||||
InputEvent::KeyUp { scancode, extended } => inj.key(scancode, extended, false),
|
InputEvent::KeyUp { scancode, extended } => inj.key(scancode, extended, false),
|
||||||
|
InputEvent::Text { units } => {
|
||||||
|
// 上限在前端截断,截断必须可见(否则用户以为长文本已送达)。
|
||||||
|
let (units, truncated) = common::clamp_text_units(units);
|
||||||
|
if truncated {
|
||||||
|
eprintln!(
|
||||||
|
"[agent-core] 文本超过上限,已截断到 {} 个 code unit",
|
||||||
|
common::MAX_TEXT_UNITS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
inj.text(&units);
|
||||||
|
}
|
||||||
|
// v4:相对移动原样下发(含负值)。clamp 与像素换算由注入后端负责——
|
||||||
|
// 只有它知道被控端桌面的真实像素尺寸(`agent-core` 是平台无关层)。
|
||||||
|
InputEvent::MouseMoveRel { dx, dy } => inj.mouse_move_rel(dx, dy),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,11 +134,14 @@ pub struct EncoderSettings {
|
|||||||
|
|
||||||
impl Default for EncoderSettings {
|
impl Default for EncoderSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
// 对标 UU:屏幕内容实时档;关键帧周期 300 帧 ≈ 10s
|
// 关键帧周期:以**时间**为准而非固定帧数,避免帧率变化后恢复窗口漂移。
|
||||||
|
// 1 秒 @30fps = 30 帧。主恢复路径是 KeyFrameReq(协议 v2 后已确认生效),
|
||||||
|
// 自动 IDR 只是兜底;此前 300 帧 @30fps 意味着兜底窗口长达 10 秒。
|
||||||
|
let max_fps = 30.0f32;
|
||||||
Self {
|
Self {
|
||||||
bitrate_bps: 5_000_000,
|
bitrate_bps: 5_000_000,
|
||||||
max_fps: 30.0,
|
max_fps,
|
||||||
intra_period: 300,
|
intra_period: (max_fps as u32).max(1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -125,23 +183,32 @@ enum LoopEnd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 编码一帧并推入下行通道。返回 false 表示通道已关闭(会话结束)。
|
/// 编码一帧并推入下行通道。返回 false 表示通道已关闭(会话结束)。
|
||||||
|
///
|
||||||
|
/// `seq` 由调用方持有并自增:帧序号必须覆盖**每一次实际发送**(含静止桌面重发),
|
||||||
|
/// 不能由编码器计数(编码器只知道编码了多少帧,不知道发了多少帧)。
|
||||||
fn encode_send(
|
fn encode_send(
|
||||||
encoder: &mut Encoder,
|
encoder: &mut Encoder,
|
||||||
raw: &[u8],
|
raw: &[u8],
|
||||||
(w, h): (u32, u32),
|
(w, h): (u32, u32),
|
||||||
ts_ms: u64,
|
ts_ms: u64,
|
||||||
|
seq: u64,
|
||||||
|
cursor: Option<(i16, i16)>,
|
||||||
tx: &watch::Sender<Option<Vec<u8>>>,
|
tx: &watch::Sender<Option<Vec<u8>>>,
|
||||||
last_frame: &mut Option<Vec<u8>>,
|
last_frame: &mut Option<Vec<u8>>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let header = VideoFrameHeader {
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
ts_ms,
|
|
||||||
};
|
|
||||||
let yuv = YUVBuffer::from_bgra8_source(BgraSliceU8::new(raw, (w as usize, h as usize)));
|
let yuv = YUVBuffer::from_bgra8_source(BgraSliceU8::new(raw, (w as usize, h as usize)));
|
||||||
match encoder.encode(&yuv) {
|
match encoder.encode(&yuv) {
|
||||||
Ok(ebs) => {
|
Ok(ebs) => {
|
||||||
let frame = encode_video_frame(&header, &ebs.to_vec());
|
let payload = ebs.to_vec();
|
||||||
|
let header = VideoFrameHeader {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
ts_ms,
|
||||||
|
seq,
|
||||||
|
keyframe: h264_contains_idr(&payload),
|
||||||
|
cursor,
|
||||||
|
};
|
||||||
|
let frame = encode_video_frame(&header, &payload);
|
||||||
if tx.send(Some(frame.clone())).is_err() {
|
if tx.send(Some(frame.clone())).is_err() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -178,8 +245,17 @@ fn capture_session(
|
|||||||
// 保留最近一帧裸像素:静止桌面收到关键帧请求时,仍能重编一个真关键帧
|
// 保留最近一帧裸像素:静止桌面收到关键帧请求时,仍能重编一个真关键帧
|
||||||
let mut last_raw: Option<Vec<u8>> = None;
|
let mut last_raw: Option<Vec<u8>> = None;
|
||||||
let mut last_sent = Instant::now() - Duration::from_secs(10);
|
let mut last_sent = Instant::now() - Duration::from_secs(10);
|
||||||
|
// 帧序号:覆盖每一次实际发送(含静止桌面重发),供主控端做丢帧检测
|
||||||
|
let mut seq: u64 = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// 会话结束守卫:发送端已 drop 接收器时立即退出。
|
||||||
|
// 与 agent/src/capture.rs 同源——静止桌面分支不发送,只靠 send 失败退出
|
||||||
|
// 会让线程在画面不动时永不退出(X11 无 DXGI 式独占,危害较小但同样泄漏线程)。
|
||||||
|
if tx.receiver_count() == 0 {
|
||||||
|
eprintln!("[capture] no receiver, stop");
|
||||||
|
return LoopEnd::ChannelClosed;
|
||||||
|
}
|
||||||
let iter_start = Instant::now();
|
let iter_start = Instant::now();
|
||||||
match cap.acquire(80) {
|
match cap.acquire(80) {
|
||||||
Ok(true) => match cap.copy_frame() {
|
Ok(true) => match cap.copy_frame() {
|
||||||
@ -189,9 +265,19 @@ fn capture_session(
|
|||||||
encoder.force_intra_frame();
|
encoder.force_intra_frame();
|
||||||
}
|
}
|
||||||
let ts = start.elapsed().as_millis() as u64;
|
let ts = start.elapsed().as_millis() as u64;
|
||||||
if !encode_send(&mut encoder, &raw, (w, h), ts, tx, &mut last_frame) {
|
if !encode_send(
|
||||||
|
&mut encoder,
|
||||||
|
&raw,
|
||||||
|
(w, h),
|
||||||
|
ts,
|
||||||
|
seq,
|
||||||
|
cap.pointer_state(),
|
||||||
|
tx,
|
||||||
|
&mut last_frame,
|
||||||
|
) {
|
||||||
return LoopEnd::ChannelClosed; // 会话结束
|
return LoopEnd::ChannelClosed; // 会话结束
|
||||||
}
|
}
|
||||||
|
seq = seq.wrapping_add(1);
|
||||||
last_raw = Some(raw);
|
last_raw = Some(raw);
|
||||||
last_sent = Instant::now();
|
last_sent = Instant::now();
|
||||||
// 帧间隔节流到 max_fps
|
// 帧间隔节流到 max_fps
|
||||||
@ -206,24 +292,68 @@ fn capture_session(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
|
// 静止桌面:先看关键帧请求,再决定是否走 500ms 保活节流。
|
||||||
|
//
|
||||||
|
// ⚠️ 这里必须保证「消费了标志就一定发送」:先前写法是
|
||||||
|
// `if force && last_raw.is_some() { ... }`,当 force 为真但
|
||||||
|
// last_raw 还是 None(刚建会话、尚未抓到任何一帧)时,标志被
|
||||||
|
// swap 消费掉却没有发送,解码端的这次请求就永久丢失。
|
||||||
let force = keyreq.swap(false, Ordering::Relaxed);
|
let force = keyreq.swap(false, Ordering::Relaxed);
|
||||||
if force && last_raw.is_some() {
|
let can_key = last_raw.is_some();
|
||||||
|
if force && !can_key {
|
||||||
|
// 还没有裸帧可重编:把请求还回去,让下一轮(拿到首帧后)继续生效
|
||||||
|
keyreq.store(true, Ordering::Relaxed);
|
||||||
|
eprintln!("[capture] KeyFrameReq 暂存:尚无裸帧可重编");
|
||||||
|
std::thread::sleep(Duration::from_millis(8));
|
||||||
|
} else if force {
|
||||||
// 静止桌面下的关键帧恢复:画面没变不代表可以不给关键帧——
|
// 静止桌面下的关键帧恢复:画面没变不代表可以不给关键帧——
|
||||||
// 解码端一旦出错,若只会收到旧 P 帧就永远恢复不了
|
// 解码端一旦出错,若只会收到旧 P 帧就永远恢复不了。
|
||||||
|
// 关键帧请求绕过 500ms 节流:它是错误恢复路径,延迟一次 IDR
|
||||||
|
// 的代价是持续黑屏,远高于多发一帧的带宽成本。
|
||||||
encoder.force_intra_frame();
|
encoder.force_intra_frame();
|
||||||
let raw = last_raw.as_ref().expect("上面已判断 is_some");
|
let raw = last_raw.as_ref().expect("can_key 已保证");
|
||||||
let ts = start.elapsed().as_millis() as u64;
|
let ts = start.elapsed().as_millis() as u64;
|
||||||
if !encode_send(&mut encoder, raw, (w, h), ts, tx, &mut last_frame) {
|
if !encode_send(
|
||||||
|
&mut encoder,
|
||||||
|
raw,
|
||||||
|
(w, h),
|
||||||
|
ts,
|
||||||
|
seq,
|
||||||
|
cap.pointer_state(),
|
||||||
|
tx,
|
||||||
|
&mut last_frame,
|
||||||
|
) {
|
||||||
return LoopEnd::ChannelClosed;
|
return LoopEnd::ChannelClosed;
|
||||||
}
|
}
|
||||||
|
seq = seq.wrapping_add(1);
|
||||||
last_sent = Instant::now();
|
last_sent = Instant::now();
|
||||||
} else if last_sent.elapsed() > Duration::from_millis(500) && last_frame.is_some() {
|
} else if last_sent.elapsed() > Duration::from_millis(500) && last_frame.is_some() {
|
||||||
// 桌面静止:低频重发最后一帧(重复 P 帧,解码端画面不变)
|
// 桌面静止:低频重发最后一帧(重复 P 帧,解码端画面不变)。
|
||||||
|
// 帧头必须重建:直接 clone 旧帧会让 seq/ts_ms 停在上次发送时刻,
|
||||||
|
// 破坏 seq 单调递增契约。
|
||||||
let f = last_frame.as_ref().expect("上面已判断 is_some");
|
let f = last_frame.as_ref().expect("上面已判断 is_some");
|
||||||
if tx.send(Some(f.clone())).is_err() {
|
match common::split_video_frame(f) {
|
||||||
return LoopEnd::ChannelClosed;
|
Ok((mut hdr, payload)) => {
|
||||||
|
hdr.seq = seq;
|
||||||
|
hdr.ts_ms = start.elapsed().as_millis() as u64;
|
||||||
|
// v5 与 seq/ts_ms 同理:重发帧的 cursor 若沿用旧值会让标记
|
||||||
|
// 冻住(画面静止≠指针静止,触控板用户尤甚)
|
||||||
|
hdr.cursor = cap.pointer_state();
|
||||||
|
let frame = encode_video_frame(&hdr, payload);
|
||||||
|
if tx.send(Some(frame)).is_err() {
|
||||||
|
return LoopEnd::ChannelClosed;
|
||||||
|
}
|
||||||
|
seq = seq.wrapping_add(1);
|
||||||
|
last_sent = Instant::now();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// 自己产出的帧解析失败 = 编码/封装路径有 bug。
|
||||||
|
// 丢弃缓存而不是每轮重复报错:静止桌面下这里会以 ~125Hz 触发。
|
||||||
|
eprintln!("[capture] 静止重发时帧格式异常,丢弃缓存: {e}");
|
||||||
|
last_frame = None;
|
||||||
|
std::thread::sleep(Duration::from_millis(8));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
last_sent = Instant::now();
|
|
||||||
} else {
|
} else {
|
||||||
std::thread::sleep(Duration::from_millis(8));
|
std::thread::sleep(Duration::from_millis(8));
|
||||||
}
|
}
|
||||||
@ -318,20 +448,46 @@ where
|
|||||||
let (mut send, mut recv) = conn.accept_bi().await?;
|
let (mut send, mut recv) = conn.accept_bi().await?;
|
||||||
|
|
||||||
let first = read_control(&mut recv).await?;
|
let first = read_control(&mut recv).await?;
|
||||||
let code_ok = matches!(&first, ControlMsg::Auth { code: c } if *c == cfg.code);
|
// 先校验版本,再校验验证码:版本不一致时字段布局可能不同,验证码的解析结果不可信
|
||||||
if !code_ok {
|
let auth_err: Option<String> = match &first {
|
||||||
write_control(&mut send, &ControlMsg::AuthFail("bad code".into())).await?;
|
ControlMsg::Auth {
|
||||||
|
code: c,
|
||||||
|
proto_version,
|
||||||
|
} => {
|
||||||
|
if *proto_version != common::PROTO_VERSION {
|
||||||
|
Some(format!(
|
||||||
|
"protocol version mismatch: client={proto_version}, server={}",
|
||||||
|
common::PROTO_VERSION
|
||||||
|
))
|
||||||
|
} else if *c != cfg.code {
|
||||||
|
Some("bad code".into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => Some(format!("expected Auth, got {other:?}")),
|
||||||
|
};
|
||||||
|
if let Some(msg) = auth_err {
|
||||||
|
write_control(&mut send, &ControlMsg::AuthFail(msg.clone())).await?;
|
||||||
send.finish()?;
|
send.finish()?;
|
||||||
// 必须等对端读到 AuthFail 并挂断再退出:直接 return 会让进程带走 QUIC
|
// 必须等对端读到 AuthFail 并挂断再退出:直接 return 会让进程带走 QUIC
|
||||||
// 缓冲区里尚未发出的数据,客户端只会看到 "connection lost" 而不是 AuthFail
|
// 缓冲区里尚未发出的数据,客户端只会看到 "connection lost" 而不是 AuthFail
|
||||||
let _ = tokio::time::timeout(Duration::from_secs(5), conn.closed()).await;
|
let _ = tokio::time::timeout(Duration::from_secs(5), conn.closed()).await;
|
||||||
anyhow::bail!("auth failed");
|
anyhow::bail!(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 采集后端先建一次:既拿桌面尺寸回给主控端,也避免白建一个 duplication 只为读尺寸
|
// 采集后端先建一次:既拿桌面尺寸回给主控端,也避免白建一个 duplication 只为读尺寸
|
||||||
let first_cap = capture_factory()?;
|
let first_cap = capture_factory()?;
|
||||||
let (w, h) = first_cap.size();
|
let (w, h) = first_cap.size();
|
||||||
write_control(&mut send, &ControlMsg::AuthOk { width: w, height: h }).await?;
|
write_control(
|
||||||
|
&mut send,
|
||||||
|
&ControlMsg::AuthOk {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
proto_version: common::PROTO_VERSION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
println!("[agent] auth ok, desktop {w}x{h}");
|
println!("[agent] auth ok, desktop {w}x{h}");
|
||||||
|
|
||||||
// 流 2:键鼠输入 + 控制上行(与视频分离,避免被视频积压阻塞)
|
// 流 2:键鼠输入 + 控制上行(与视频分离,避免被视频积压阻塞)
|
||||||
@ -405,3 +561,193 @@ where
|
|||||||
println!("[agent] session closed");
|
println!("[agent] session closed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// 记录调用轨迹的注入后端:验证分发与截断,不碰任何真实输入。
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MockInjector {
|
||||||
|
calls: Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockInjector {
|
||||||
|
fn take(&self) -> Vec<String> {
|
||||||
|
let mut g = self.calls.lock().unwrap();
|
||||||
|
std::mem::take(&mut g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputInjector for MockInjector {
|
||||||
|
fn mouse_move(&self, x: u16, y: u16) {
|
||||||
|
self.calls.lock().unwrap().push(format!("move {x},{y}"));
|
||||||
|
}
|
||||||
|
fn mouse_button(&self, b: MouseButton, down: bool) {
|
||||||
|
self.calls.lock().unwrap().push(format!("btn {b:?} {down}"));
|
||||||
|
}
|
||||||
|
fn wheel(&self, dy: i32) {
|
||||||
|
self.calls.lock().unwrap().push(format!("wheel {dy}"));
|
||||||
|
}
|
||||||
|
fn key(&self, scancode: u16, extended: bool, down: bool) {
|
||||||
|
self.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(format!("key {scancode:#x} ext={extended} {down}"));
|
||||||
|
}
|
||||||
|
// 刻意**不**实现 text():让默认实现进入测试面,验证「后端没做文本注入
|
||||||
|
// 时也必须被 dispatch_input 正常调用、且不 panic」——Linux 端正是这个形态。
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实现了 text() 的后端,用来验证截断后的长度真的传给了注入侧。
|
||||||
|
struct TextCapableInjector {
|
||||||
|
received: Mutex<Vec<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputInjector for TextCapableInjector {
|
||||||
|
fn mouse_move(&self, _x: u16, _y: u16) {}
|
||||||
|
fn mouse_button(&self, _b: MouseButton, _down: bool) {}
|
||||||
|
fn wheel(&self, _dy: i32) {}
|
||||||
|
fn key(&self, _scancode: u16, _extended: bool, _down: bool) {}
|
||||||
|
fn text(&self, units: &[u16]) {
|
||||||
|
self.received.lock().unwrap().push(units.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_routes_each_event_kind() {
|
||||||
|
let inj = MockInjector::default();
|
||||||
|
dispatch_input(&inj, InputEvent::MouseMove { x: 1, y: 2 });
|
||||||
|
dispatch_input(&inj, InputEvent::MouseDown(MouseButton::Right));
|
||||||
|
dispatch_input(&inj, InputEvent::Wheel { dy: -120 });
|
||||||
|
dispatch_input(
|
||||||
|
&inj,
|
||||||
|
InputEvent::KeyDown {
|
||||||
|
scancode: 0x48,
|
||||||
|
extended: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let calls = inj.take();
|
||||||
|
assert_eq!(
|
||||||
|
calls,
|
||||||
|
vec![
|
||||||
|
"move 1,2",
|
||||||
|
"btn Right true",
|
||||||
|
"wheel -120",
|
||||||
|
"key 0x48 ext=true true",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v4:相对移动必须被分发,**负值原样到达后端**(负值是相对语义的核心,
|
||||||
|
/// 绝对坐标是无符号的,这条路径是新引入的有符号通道)。
|
||||||
|
#[test]
|
||||||
|
fn rel_move_reaches_backend_with_signed_deltas() {
|
||||||
|
struct RelInjector {
|
||||||
|
got: Mutex<Vec<(i16, i16)>>,
|
||||||
|
}
|
||||||
|
impl InputInjector for RelInjector {
|
||||||
|
fn mouse_move(&self, _x: u16, _y: u16) {}
|
||||||
|
fn mouse_button(&self, _b: MouseButton, _down: bool) {}
|
||||||
|
fn wheel(&self, _dy: i32) {}
|
||||||
|
fn key(&self, _s: u16, _e: bool, _d: bool) {}
|
||||||
|
fn mouse_move_rel(&self, dx: i16, dy: i16) {
|
||||||
|
self.got.lock().unwrap().push((dx, dy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let inj = RelInjector {
|
||||||
|
got: Mutex::new(Vec::new()),
|
||||||
|
};
|
||||||
|
dispatch_input(&inj, InputEvent::MouseMoveRel { dx: 12, dy: -34 });
|
||||||
|
dispatch_input(&inj, InputEvent::MouseMoveRel { dx: -1, dy: 0 });
|
||||||
|
assert_eq!(
|
||||||
|
*inj.got.lock().unwrap(),
|
||||||
|
vec![(12, -34), (-1, 0)],
|
||||||
|
"相对增量(含负值)必须原样到达注入后端"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 后端**没有**实现 mouse_move_rel 时不得 panic,且相对事件不得被误路由成
|
||||||
|
/// 绝对移动(Linux/X11 后端的真实形态:默认实现 + 日志)。
|
||||||
|
#[test]
|
||||||
|
fn rel_move_default_backend_does_not_panic() {
|
||||||
|
let inj = MockInjector::default();
|
||||||
|
dispatch_input(&inj, InputEvent::MouseMoveRel { dx: 5, dy: 5 });
|
||||||
|
// 默认实现只打日志,不应产生任何注入调用(尤其不能变成 move/绝对移动)
|
||||||
|
assert!(
|
||||||
|
inj.take().is_empty(),
|
||||||
|
"默认实现不得把相对移动降级成绝对移动"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v3 关键回归:`Text` 必须被分发,且**后端没有实现 text() 时不能 panic**。
|
||||||
|
/// 这是 Linux/X11 后端的真实形态——用户对着 Linux 被控端打字会走到这里。
|
||||||
|
#[test]
|
||||||
|
fn text_event_reaches_default_backend_without_panic() {
|
||||||
|
let inj = MockInjector::default();
|
||||||
|
dispatch_input(
|
||||||
|
&inj,
|
||||||
|
InputEvent::Text {
|
||||||
|
units: vec![0x4F60, 0x597D],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// 默认实现只记日志,不产生调用记录;此处断言"没炸"即为通过。
|
||||||
|
assert!(inj.take().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 截断契约:超过 `MAX_TEXT_UNITS` 的输入必须以截断后的长度到达注入侧。
|
||||||
|
/// 不截断会让长粘贴把远端注入队列刷爆;截断不告知则是静默丢数据。
|
||||||
|
#[test]
|
||||||
|
fn text_is_clamped_to_protocol_cap() {
|
||||||
|
let inj = TextCapableInjector {
|
||||||
|
received: Mutex::new(vec![]),
|
||||||
|
};
|
||||||
|
dispatch_input(
|
||||||
|
&inj,
|
||||||
|
InputEvent::Text {
|
||||||
|
units: vec![0x61; common::MAX_TEXT_UNITS + 500],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
dispatch_input(
|
||||||
|
&inj,
|
||||||
|
InputEvent::Text {
|
||||||
|
units: vec![0x62; 10],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let got = inj.received.lock().unwrap().clone();
|
||||||
|
assert_eq!(got, vec![common::MAX_TEXT_UNITS, 10], "超限截断、未超限原样");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空 Text 仍会调用后端(是否忽略由后端决定):主控侧 `send_text` 已拦掉空输入,
|
||||||
|
/// 但被控端不能假设对端守规矩——线格式输入一律按不可信处理。
|
||||||
|
#[test]
|
||||||
|
fn empty_text_still_dispatches() {
|
||||||
|
let inj = TextCapableInjector {
|
||||||
|
received: Mutex::new(vec![]),
|
||||||
|
};
|
||||||
|
dispatch_input(&inj, InputEvent::Text { units: vec![] });
|
||||||
|
assert_eq!(inj.received.lock().unwrap().clone(), vec![0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v5:采集后端未实现 `pointer_state` 时必须返回 None 且不 panic——Linux/X11 端
|
||||||
|
/// 本轮的真实形态(4b/Q4=A:默认 None + 一次性日志,实装另立循环)。
|
||||||
|
/// 锁的是「默认实现存在且语义为 None」这条契约:若有人把默认实现改成 panic 或
|
||||||
|
/// 伪造坐标,这条会红。
|
||||||
|
#[test]
|
||||||
|
fn pointer_state_default_returns_none_without_panic() {
|
||||||
|
struct NoPointerCapturer;
|
||||||
|
impl ScreenCapturer for NoPointerCapturer {
|
||||||
|
fn size(&self) -> (u32, u32) {
|
||||||
|
(100, 100)
|
||||||
|
}
|
||||||
|
fn acquire(&mut self, _timeout_ms: u32) -> Result<bool> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
fn copy_frame(&mut self) -> Result<Vec<u8>> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(NoPointerCapturer.pointer_state(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -12,7 +12,10 @@ rustls.workspace = true
|
|||||||
rcgen.workspace = true
|
rcgen.workspace = true
|
||||||
lz4_flex.workspace = true
|
lz4_flex.workspace = true
|
||||||
openh264 = { version = "0.9.9", features = ["source"] }
|
openh264 = { version = "0.9.9", features = ["source"] }
|
||||||
nvenc = "0.1.0"
|
# 上游 nvenc 0.1.0 的 encode_picture 只设 picture_type=IDR、不置 FORCEIDR 标志,
|
||||||
|
# 导致运行期 KeyFrameReq 被编码器静默忽略(实测输出 NAL type=1 而非 5)。
|
||||||
|
# vendor 版本补上了该标志,见 vendor/nvenc/src/safe/encoder.rs 的「本项目补丁」注释。
|
||||||
|
nvenc = { path = "../vendor/nvenc" }
|
||||||
windows = { workspace = true, features = [
|
windows = { workspace = true, features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Graphics_Direct3D",
|
"Win32_Graphics_Direct3D",
|
||||||
@ -21,4 +24,7 @@ windows = { workspace = true, features = [
|
|||||||
"Win32_Graphics_Dxgi_Common",
|
"Win32_Graphics_Dxgi_Common",
|
||||||
"Win32_UI_Input_KeyboardAndMouse",
|
"Win32_UI_Input_KeyboardAndMouse",
|
||||||
"Win32_UI_WindowsAndMessaging",
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
# SetProcessDpiAwarenessContext:相对鼠标的 GetCursorPos 必须与采集同为物理像素,
|
||||||
|
# 不能依赖注册表 AppCompat 覆盖(见 3b 开工实测 §4)。
|
||||||
|
"Win32_UI_HiDpi",
|
||||||
] }
|
] }
|
||||||
|
|||||||
326
agent/src/bin/dump_stream.rs
Normal file
326
agent/src/bin/dump_stream.rs
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
//! 临时诊断工具:抓 agent 下发的码流,逐帧打印 NAL 结构并试解码。
|
||||||
|
//!
|
||||||
|
//! 目的:定位「首帧能解、之后连续 decode failed」的根因,不靠猜。
|
||||||
|
//!
|
||||||
|
//! 用法:
|
||||||
|
//! cargo run --release -p agent --bin dump_stream -- --port 47805 --code t
|
||||||
|
//! cargo run --release -p agent --bin dump_stream -- --port 47805 --code t --skip-at 2
|
||||||
|
//!
|
||||||
|
//! `--skip-at N`:模拟主控端的「积压跳帧」行为——第 N 帧读出但不喂给解码器,
|
||||||
|
//! 用来验证「跳帧是否直接导致后续所有 P 帧解不出来」。
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use common::{read_control, read_video_frame, write_control, ControlMsg};
|
||||||
|
use openh264::decoder::Decoder;
|
||||||
|
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||||
|
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||||
|
use rustls::{DigitallySignedStruct, SignatureScheme};
|
||||||
|
use std::net::ToSocketAddrs;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SkipVerify;
|
||||||
|
impl ServerCertVerifier for SkipVerify {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
_e: &CertificateDer<'_>,
|
||||||
|
_i: &[CertificateDer<'_>],
|
||||||
|
_s: &ServerName<'_>,
|
||||||
|
_o: &[u8],
|
||||||
|
_n: UnixTime,
|
||||||
|
) -> Result<ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_m: &[u8],
|
||||||
|
_c: &CertificateDer<'_>,
|
||||||
|
_d: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
_m: &[u8],
|
||||||
|
_c: &CertificateDer<'_>,
|
||||||
|
_d: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||||
|
vec![
|
||||||
|
SignatureScheme::RSA_PKCS1_SHA256,
|
||||||
|
SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||||
|
SignatureScheme::ED25519,
|
||||||
|
SignatureScheme::RSA_PSS_SHA256,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫 Annex-B 起始码,返回 NAL 类型列表(如 [7, 8, 5])
|
||||||
|
fn nal_types(payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 3 < payload.len() {
|
||||||
|
let sc = if payload[i..].starts_with(&[0, 0, 0, 1]) {
|
||||||
|
4
|
||||||
|
} else if payload[i..].starts_with(&[0, 0, 1]) {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match payload.get(i + sc) {
|
||||||
|
Some(&b) => {
|
||||||
|
out.push(b & 0x1F);
|
||||||
|
i += sc + 1;
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe(types: &[u8]) -> String {
|
||||||
|
let has = |t: u8| types.contains(&t);
|
||||||
|
let mut s = String::new();
|
||||||
|
if has(7) {
|
||||||
|
s.push_str("SPS ");
|
||||||
|
}
|
||||||
|
if has(8) {
|
||||||
|
s.push_str("PPS ");
|
||||||
|
}
|
||||||
|
if has(5) {
|
||||||
|
s.push_str("IDR ");
|
||||||
|
}
|
||||||
|
if has(1) {
|
||||||
|
s.push_str("P ");
|
||||||
|
}
|
||||||
|
if has(6) {
|
||||||
|
s.push_str("SEI ");
|
||||||
|
}
|
||||||
|
if s.is_empty() {
|
||||||
|
s.push_str("(未知)");
|
||||||
|
}
|
||||||
|
format!("{}[{}]", s.trim(), types.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arg(name: &str) -> Option<String> {
|
||||||
|
let a: Vec<String> = std::env::args().collect();
|
||||||
|
a.iter()
|
||||||
|
.position(|x| x == name)
|
||||||
|
.and_then(|i| a.get(i + 1))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v5 观测列:帧头光标字段的稳定文本形态(proof 脚本按 `cursor=(x,y)` / `cursor=None` 解析)
|
||||||
|
fn cursor_cell(c: &Option<(i16, i16)>) -> String {
|
||||||
|
match c {
|
||||||
|
Some((x, y)) => format!("cursor=({x},{y})"),
|
||||||
|
None => "cursor=None".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let port: u16 = arg("--port")
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(47800);
|
||||||
|
let code = arg("--code").unwrap_or_else(|| "123456".into());
|
||||||
|
let skip_at: Option<usize> = arg("--skip-at").map(|s| s.parse()).transpose()?;
|
||||||
|
let total: usize = arg("--frames")
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(30);
|
||||||
|
// --save <路径>:把**裸 H.264 码流**(各帧载荷按到达顺序直接拼接)
|
||||||
|
// 写成 Annex-B 文件,供安卓 MediaCodec 等外部解码器验证兼容性。
|
||||||
|
// 注意存的是载荷而非完整帧(不含自定义帧头),因为外部解码器只认标准 Annex-B。
|
||||||
|
let save_path = arg("--save");
|
||||||
|
|
||||||
|
let tls = rustls::ClientConfig::builder()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(SkipVerify))
|
||||||
|
.with_no_client_auth();
|
||||||
|
let qc: quinn::crypto::rustls::QuicClientConfig = Arc::new(tls).try_into()?;
|
||||||
|
let mut transport = quinn::TransportConfig::default();
|
||||||
|
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
|
||||||
|
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
|
||||||
|
let mut client_config = quinn::ClientConfig::new(Arc::new(qc));
|
||||||
|
client_config.transport_config(Arc::new(transport));
|
||||||
|
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?;
|
||||||
|
endpoint.set_default_client_config(client_config);
|
||||||
|
|
||||||
|
let addr = ("127.0.0.1", port)
|
||||||
|
.to_socket_addrs()?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("解析失败"))?;
|
||||||
|
let conn = endpoint.connect(addr, "localhost")?.await?;
|
||||||
|
println!("[dump] connected");
|
||||||
|
|
||||||
|
let (mut send, mut recv) = conn.open_bi().await?;
|
||||||
|
write_control(
|
||||||
|
&mut send,
|
||||||
|
&ControlMsg::Auth {
|
||||||
|
code,
|
||||||
|
proto_version: common::PROTO_VERSION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
match read_control(&mut recv).await? {
|
||||||
|
ControlMsg::AuthOk {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
proto_version,
|
||||||
|
} => println!("[dump] auth ok {width}x{height} proto={proto_version}"),
|
||||||
|
other => bail!("认证失败: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let (mut isend, _irecv) = conn.open_bi().await?;
|
||||||
|
|
||||||
|
// 入会先请求一次 IDR,和 viewer 行为一致。
|
||||||
|
// 注意:KeyFrameReq 必须走**流 2**(isend),agent 的读循环只监听流 2。
|
||||||
|
write_control(&mut isend, &ControlMsg::KeyFrameReq).await?;
|
||||||
|
|
||||||
|
let mut decoder = Decoder::new()?;
|
||||||
|
let mut sent_kfreq = false;
|
||||||
|
// 累积裸码流,用于 --save
|
||||||
|
let mut saved: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
|
println!("\n序号 seq kf 载荷字节 NAL 结构 光标 解码结果");
|
||||||
|
println!("{}", "-".repeat(86));
|
||||||
|
|
||||||
|
for i in 0..total {
|
||||||
|
let (hdr, payload) = match tokio::time::timeout(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
read_video_frame(&mut recv),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r?,
|
||||||
|
Err(_) => {
|
||||||
|
println!("[{i}] 读帧超时(桌面可能完全静止)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let types = nal_types(&payload);
|
||||||
|
let is_idr = types.contains(&5);
|
||||||
|
|
||||||
|
// 需要在 save 里也保留这一帧时,先记录到缓冲
|
||||||
|
if save_path.is_some() {
|
||||||
|
saved.extend_from_slice(&payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交叉校验:帧头 keyframe 标志必须与码流实际 NAL 类型一致。
|
||||||
|
// 这条不一致意味着时间戳/序号契约或编码器标志有 bug,比画面花屏更难排查。
|
||||||
|
if hdr.keyframe != is_idr {
|
||||||
|
println!(
|
||||||
|
" ⚠️ 帧头 keyframe={} 但码流实际 IDR={}(seq={})",
|
||||||
|
hdr.keyframe, is_idr, hdr.seq
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟主控端积压跳帧:读出但不解码
|
||||||
|
if Some(i) == skip_at {
|
||||||
|
println!(
|
||||||
|
"[{i:2}] {:3} {:<5} {:8} {:<14} {:14} ⏭ 跳过解码(模拟积压跳帧)",
|
||||||
|
hdr.seq,
|
||||||
|
hdr.keyframe,
|
||||||
|
payload.len(),
|
||||||
|
describe(&types),
|
||||||
|
cursor_cell(&hdr.cursor)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = match decoder.decode(&payload) {
|
||||||
|
Ok(Some(_)) => "✅ 解出画面".to_string(),
|
||||||
|
Ok(None) => "○ 无输出(缓冲中)".to_string(),
|
||||||
|
Err(e) => format!("❌ {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[{i:2}] {:3} {:<5} {:8} {:<14} {:14} {}",
|
||||||
|
hdr.seq,
|
||||||
|
hdr.keyframe,
|
||||||
|
payload.len(),
|
||||||
|
describe(&types),
|
||||||
|
cursor_cell(&hdr.cursor),
|
||||||
|
res
|
||||||
|
);
|
||||||
|
|
||||||
|
// 和 viewer 一样:解码失败就请求关键帧
|
||||||
|
if res.starts_with('❌') && !sent_kfreq {
|
||||||
|
sent_kfreq = true;
|
||||||
|
write_control(&mut isend, &ControlMsg::KeyFrameReq).await?;
|
||||||
|
println!(" ↑ 已发 KeyFrameReq");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 诊断:静止桌面下再请求一次关键帧,看是否真能拿到 IDR
|
||||||
|
println!("\n[dump] 再发一次 KeyFrameReq,观察 10 帧…");
|
||||||
|
write_control(&mut isend, &ControlMsg::KeyFrameReq).await?;
|
||||||
|
for i in 0..10 {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(5), read_video_frame(&mut recv)).await {
|
||||||
|
Ok(Ok((hdr, payload))) => {
|
||||||
|
let types = nal_types(&payload);
|
||||||
|
println!(
|
||||||
|
"[k{i:2}] seq={:3} kf={:<5} {:6}B {}",
|
||||||
|
hdr.seq,
|
||||||
|
hdr.keyframe,
|
||||||
|
payload.len(),
|
||||||
|
describe(&types)
|
||||||
|
);
|
||||||
|
if types.contains(&5) {
|
||||||
|
println!(" ↑ 收到 IDR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
println!("[k{i:2}] 超时/流关闭");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出裸码流文件(Annex-B 拼接),供外部解码器验证
|
||||||
|
if let Some(p) = save_path.as_ref() {
|
||||||
|
std::fs::write(p, &saved)?;
|
||||||
|
let idr_cnt = {
|
||||||
|
// 粗略统计 IDR 数量:扫起始码后的 NAL 类型
|
||||||
|
let mut n = 0;
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 3 < saved.len() {
|
||||||
|
let sc = if saved[i..].starts_with(&[0, 0, 0, 1]) {
|
||||||
|
4
|
||||||
|
} else if saved[i..].starts_with(&[0, 0, 1]) {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match saved.get(i + sc) {
|
||||||
|
Some(&b) if b & 0x1F == 5 => {
|
||||||
|
n += 1;
|
||||||
|
i += sc + 1;
|
||||||
|
}
|
||||||
|
Some(_) => i += sc + 1,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"\n[dump] 已保存裸码流: {p}({} 字节,含 {idr_cnt} 个 IDR)",
|
||||||
|
saved.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = conn.close(0u32.into(), b"done");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@
|
|||||||
//! 支持主控端 KeyFrameReq 强制关键帧(解码异常恢复)。
|
//! 支持主控端 KeyFrameReq 强制关键帧(解码异常恢复)。
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use common::{encode_video_frame, VideoFrameHeader};
|
use common::{encode_video_frame, h264_contains_idr, VideoFrameHeader};
|
||||||
use nvenc::bitstream::BitStream;
|
use nvenc::bitstream::BitStream;
|
||||||
use nvenc::encoder::RegisteredResource;
|
use nvenc::encoder::RegisteredResource;
|
||||||
use nvenc::session::{InitParams, Session};
|
use nvenc::session::{InitParams, Session};
|
||||||
@ -36,9 +36,46 @@ use windows::Win32::Graphics::Dxgi::{
|
|||||||
IDXGIDevice, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_WAIT_TIMEOUT,
|
IDXGIDevice, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_WAIT_TIMEOUT,
|
||||||
DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO,
|
DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO,
|
||||||
};
|
};
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{GetCursorInfo, CURSORINFO, CURSOR_SHOWING};
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// 周期 IDR 的帧间隔(兜底恢复点)。
|
||||||
|
///
|
||||||
|
/// 两个编码器共用同一常量,避免 NVENC 与软编的恢复窗口不一致——此前分别是
|
||||||
|
/// 240 帧@60fps=4s 与 300 帧@60fps=5s,而 Linux 端又是 300 帧@30fps=10s。
|
||||||
|
///
|
||||||
|
/// 取值理由:**主恢复路径是 KeyFrameReq,不是自动 IDR**(协议 v2 后已确认请求
|
||||||
|
/// 真正生效,见 vendor/nvenc 的 FORCEIDR 补丁)。自动 IDR 只用于兜底
|
||||||
|
/// 「请求连续丢失」这种极端情况。60 帧 @60fps = 1 秒,既足够兜底,
|
||||||
|
/// 又不会因过密的整帧内编码抬高码率。
|
||||||
|
pub const INTRA_PERIOD_FRAMES: u32 = 60;
|
||||||
|
|
||||||
|
/// v5:光标可见性 + 采集边界折叠的**纯逻辑**(无 Win32 调用,可单测)。
|
||||||
|
///
|
||||||
|
/// 输入是 `GetCursorInfo` 的原始读数与采集画面尺寸;输出协议 `cursor` 字段语义
|
||||||
|
/// (见 `common::VideoFrameHeader::cursor`):
|
||||||
|
/// - 未 `CURSOR_SHOWING`(指针隐藏或被触控抑制)→ None
|
||||||
|
/// - 越出 `[0,w)×[0,h)`(副屏负坐标区、主屏外右侧等)→ None
|
||||||
|
/// ——采集画面里没有它,主控端画了位置也是错的(4b/Q5=A:语义单一来源在被控端)
|
||||||
|
/// - 越出 i16 正范围 → None 且第二返回 `true`(调用方打日志):协议用 i16 省字节,
|
||||||
|
/// 依赖「现实桌面 << 32767」前提;前提破了就折叠,**绝不截断出一个看起来合法的错坐标**
|
||||||
|
///
|
||||||
|
/// 独立成函数的原因:这套判定是 4b 折叠规则的全部智力内容,而 Win32 读数在
|
||||||
|
/// 有人用真鼠标的机器上不可复现——逻辑必须与采样解耦地被测试锁死。
|
||||||
|
fn fold_cursor(showing: bool, x: i32, y: i32, w: i32, h: i32) -> (Option<(i16, i16)>, bool) {
|
||||||
|
if !showing {
|
||||||
|
return (None, false);
|
||||||
|
}
|
||||||
|
if x < 0 || y < 0 || x >= w || y >= h {
|
||||||
|
return (None, false);
|
||||||
|
}
|
||||||
|
if x > i16::MAX as i32 || y > i16::MAX as i32 {
|
||||||
|
return (None, true);
|
||||||
|
}
|
||||||
|
(Some((x as i16, y as i16)), false)
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Duplication {
|
pub struct Duplication {
|
||||||
pub device: ID3D11Device,
|
pub device: ID3D11Device,
|
||||||
context: ID3D11DeviceContext,
|
context: ID3D11DeviceContext,
|
||||||
@ -171,6 +208,42 @@ impl Duplication {
|
|||||||
Ok(r)
|
Ok(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v5:采样当前远端光标位置(**采集显示器内**的物理像素,(0,0)=画面左上角)。
|
||||||
|
///
|
||||||
|
/// 折叠规则(隐藏/离屏/i16 越界 → None)的唯一真源是 [`fold_cursor`]——
|
||||||
|
/// 那里是可单测的纯逻辑;本函数只负责 Win32 读数与失败留痕。
|
||||||
|
///
|
||||||
|
/// `GetCursorInfo` 失败 → None + 会话期只打一条日志(可见后果,但不逐帧刷屏)。
|
||||||
|
///
|
||||||
|
/// 前提:进程必须 per-monitor DPI 感知(`main.rs::init_dpi_awareness`,3b/D2 教训),
|
||||||
|
/// 否则 `ptScreenPos` 是逻辑坐标,与采集像素不同尺度。
|
||||||
|
pub fn sample_cursor(&self) -> Option<(i16, i16)> {
|
||||||
|
static LOGGED_FAIL: AtomicBool = AtomicBool::new(false);
|
||||||
|
static LOGGED_OVERFLOW: AtomicBool = AtomicBool::new(false);
|
||||||
|
unsafe {
|
||||||
|
let mut ci = CURSORINFO::default();
|
||||||
|
// Win32 契约:调用方必须先填 cbSize,否则 API 直接拒绝(MSDN GetCursorInfo)
|
||||||
|
ci.cbSize = std::mem::size_of::<CURSORINFO>() as u32;
|
||||||
|
if GetCursorInfo(&mut ci).is_err() {
|
||||||
|
if !LOGGED_FAIL.swap(true, Ordering::Relaxed) {
|
||||||
|
eprintln!("[capture] GetCursorInfo 失败:帧头 cursor 将恒为 None");
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (pos, overflow) = fold_cursor(
|
||||||
|
ci.flags.0 & CURSOR_SHOWING.0 != 0,
|
||||||
|
ci.ptScreenPos.x,
|
||||||
|
ci.ptScreenPos.y,
|
||||||
|
self.width as i32,
|
||||||
|
self.height as i32,
|
||||||
|
);
|
||||||
|
if overflow && !LOGGED_OVERFLOW.swap(true, Ordering::Relaxed) {
|
||||||
|
eprintln!("[capture] 光标坐标超出 i16 协议范围,已折叠为 None(桌面尺寸异常?)");
|
||||||
|
}
|
||||||
|
pos
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NVENC 硬编封装:H.264 High、CBR 码率控制、UltraLowLatency 调优、无 B 帧。
|
/// NVENC 硬编封装:H.264 High、CBR 码率控制、UltraLowLatency 调优、无 B 帧。
|
||||||
@ -201,8 +274,15 @@ impl NvencEncoder {
|
|||||||
// CBR 桌面实时档:码率恒定,延迟可预测(对标 UU 3.2Mbps@60fps)
|
// CBR 桌面实时档:码率恒定,延迟可预测(对标 UU 3.2Mbps@60fps)
|
||||||
config.preset_cfg.rc_params.rate_control_mode = NVencParamsRcMode::CBR;
|
config.preset_cfg.rc_params.rate_control_mode = NVencParamsRcMode::CBR;
|
||||||
config.preset_cfg.rc_params.average_bit_rate = bitrate_bps;
|
config.preset_cfg.rc_params.average_bit_rate = bitrate_bps;
|
||||||
config.preset_cfg.gop_len = 240; // 周期 IDR(60fps 下 4s):网络异常后的自愈恢复点,
|
// 周期 IDR 是**兜底恢复点**,不是主恢复路径——主路径是 KeyFrameReq
|
||||||
// 配合 KeyFrameReq 即时恢复,避免长时间冻结
|
// (协议 v2 后已确认真正生效,见 vendor/nvenc 的 FORCEIDR 补丁)。
|
||||||
|
//
|
||||||
|
// 取值权衡:GOP 越短,丢帧后自动恢复越快,但每次 IDR 都是整帧内编码,
|
||||||
|
// 码率开销显著(1080p 桌面 IDR 通常数倍于 P 帧)。
|
||||||
|
// 60 帧 @60fps = 1s:既覆盖「KeyFrameReq 连续丢失」这种极端情况,
|
||||||
|
// 又把兜底窗口压到 1 秒以内。原先 240 帧 = 4s,在请求已可靠的前提下
|
||||||
|
// 只会让受害时长无谓地长 4 倍。
|
||||||
|
config.preset_cfg.gop_len = INTRA_PERIOD_FRAMES;
|
||||||
config.preset_cfg.frame_interval_p = 1; // 无 B 帧
|
config.preset_cfg.frame_interval_p = 1; // 无 B 帧
|
||||||
|
|
||||||
let init = InitParams {
|
let init = InitParams {
|
||||||
@ -312,7 +392,9 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
|
|||||||
.max_frame_rate(FrameRate::from_hz(60.0))
|
.max_frame_rate(FrameRate::from_hz(60.0))
|
||||||
.rate_control_mode(RateControlMode::Bitrate)
|
.rate_control_mode(RateControlMode::Bitrate)
|
||||||
.usage_type(UsageType::ScreenContentRealTime)
|
.usage_type(UsageType::ScreenContentRealTime)
|
||||||
.intra_frame_period(IntraFramePeriod::from_num_frames(300))
|
.intra_frame_period(IntraFramePeriod::from_num_frames(
|
||||||
|
INTRA_PERIOD_FRAMES,
|
||||||
|
))
|
||||||
.num_threads(4),
|
.num_threads(4),
|
||||||
) {
|
) {
|
||||||
Ok(e) => {
|
Ok(e) => {
|
||||||
@ -327,41 +409,68 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut last_frame: Option<Vec<u8>> = None; // 软编静止重发
|
// 最近一帧的**裸载荷**与关键帧标志:静止重发时要用它们重建帧头,
|
||||||
|
// 不能直接重发整帧(那样 seq/ts_ms 会停在上次发送时刻)
|
||||||
|
let mut last_payload: Option<Vec<u8>> = None;
|
||||||
|
let mut last_kf = false;
|
||||||
let mut last_sent = Instant::now() - Duration::from_secs(10);
|
let mut last_sent = Instant::now() - Duration::from_secs(10);
|
||||||
let min_frame = Duration::from_millis(16); // 60fps 上限
|
let min_frame = Duration::from_millis(16); // 60fps 上限
|
||||||
let mut scratch: Vec<u8> = Vec::new(); // 软编:行距不连续时的复用缓冲
|
let mut scratch: Vec<u8> = Vec::new(); // 软编:行距不连续时的复用缓冲
|
||||||
|
// 帧序号:每发一帧 +1(含静止桌面的重复帧),供主控端做丢帧检测
|
||||||
|
let mut seq: u64 = 0;
|
||||||
// 编码耗时统计(每 120 帧输出一次均值,ms)
|
// 编码耗时统计(每 120 帧输出一次均值,ms)
|
||||||
let mut stat_frames = 0u64;
|
let mut stat_frames = 0u64;
|
||||||
let mut stat_convert_ms = 0u64;
|
let mut stat_convert_ms = 0u64;
|
||||||
let mut stat_encode_ms = 0u64;
|
let mut stat_encode_ms = 0u64;
|
||||||
let mut stat_skipped = 0u64; // 拥塞跳帧计数
|
let mut stat_skipped = 0u64; // 拥塞跳帧计数
|
||||||
|
|
||||||
|
// `$kf` 由调用点显式给出:NVENC 路径知道 `force_key`,软编路径同理。
|
||||||
|
// 不要在这里猜——首帧和 KeyFrameReq 都会产生 IDR,猜错会让解码端错过重同步点。
|
||||||
|
//
|
||||||
|
// `cursor` 在**这里**采样(不在循环顶部):帧头语义是「本帧发送时刻的位置」。
|
||||||
|
// emit! 是唯一的发送入口,静止保活重发也走它(500ms 分支),所以新帧 /
|
||||||
|
// 静止重发 / 强制 IDR 三种发送路径天然全覆盖,无需各自补采样。
|
||||||
macro_rules! emit {
|
macro_rules! emit {
|
||||||
($payload:expr) => {{
|
($payload:expr, $kf:expr) => {{
|
||||||
let header = VideoFrameHeader {
|
let header = VideoFrameHeader {
|
||||||
width: dup.width,
|
width: dup.width,
|
||||||
height: dup.height,
|
height: dup.height,
|
||||||
ts_ms: start.elapsed().as_millis() as u64,
|
ts_ms: start.elapsed().as_millis() as u64,
|
||||||
|
seq,
|
||||||
|
keyframe: $kf,
|
||||||
|
cursor: dup.sample_cursor(),
|
||||||
};
|
};
|
||||||
let frame = encode_video_frame(&header, &$payload);
|
let payload_vec: Vec<u8> = $payload;
|
||||||
|
let frame = encode_video_frame(&header, &payload_vec);
|
||||||
if tx.send(Some(frame.clone())).is_err() {
|
if tx.send(Some(frame.clone())).is_err() {
|
||||||
return; // 会话结束
|
return; // 会话结束
|
||||||
}
|
}
|
||||||
last_frame = Some(frame);
|
last_payload = Some(payload_vec);
|
||||||
|
last_kf = $kf;
|
||||||
last_sent = Instant::now();
|
last_sent = Instant::now();
|
||||||
|
seq = seq.wrapping_add(1);
|
||||||
stat_frames += 1;
|
stat_frames += 1;
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// 会话结束守卫:发送端已 drop 接收器时立即退出。
|
||||||
|
// 只靠 `tx.send` 失败退出是不够的——桌面完全静止时这里根本不发送,
|
||||||
|
// 线程会永远持有 duplication,导致下一个会话的 DuplicateOutput 失败
|
||||||
|
// (DXGI 每个输出仅允许一个活动实例)。
|
||||||
|
if tx.receiver_count() == 0 {
|
||||||
|
eprintln!("[capture] no receiver, stop");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let iter_start = Instant::now();
|
let iter_start = Instant::now();
|
||||||
match dup.acquire(80) {
|
match dup.acquire(80) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
// 主控端请求关键帧(解码异常恢复)
|
|
||||||
let force_key = keyreq.swap(false, Ordering::Relaxed);
|
|
||||||
// 拥塞反馈:发送侧排队深 → 跳过本帧(NVENC 少输入一帧,参考链连续;
|
// 拥塞反馈:发送侧排队深 → 跳过本帧(NVENC 少输入一帧,参考链连续;
|
||||||
// 解码端收到的帧序 = 编码端实际输出序,无花屏风险)
|
// 解码端收到的帧序 = 编码端实际输出序,无花屏风险)
|
||||||
|
//
|
||||||
|
// ⚠️ 必须放在消费 keyreq **之前**:先前顺序是「先 swap 再判断拥塞」,
|
||||||
|
// 一旦本帧因拥塞被跳过,已消费的 KeyFrameReq 就随 continue 一起丢失,
|
||||||
|
// 解码端等不到 IDR(与下方静止分支同源的缺陷)。
|
||||||
if congestion.swap(false, Ordering::Relaxed) {
|
if congestion.swap(false, Ordering::Relaxed) {
|
||||||
stat_skipped += 1;
|
stat_skipped += 1;
|
||||||
let el = iter_start.elapsed();
|
let el = iter_start.elapsed();
|
||||||
@ -370,13 +479,19 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 主控端请求关键帧(解码异常恢复)。走到这里一定会编码,
|
||||||
|
// 因此可以安全地消费标志。
|
||||||
|
let force_key = keyreq.swap(false, Ordering::Relaxed);
|
||||||
match &mut encoder {
|
match &mut encoder {
|
||||||
EncoderKind::Nvenc(enc) => {
|
EncoderKind::Nvenc(enc) => {
|
||||||
let enc_start = Instant::now();
|
let enc_start = Instant::now();
|
||||||
let r = enc.encode(force_key, start.elapsed().as_millis() as u64);
|
let r = enc.encode(force_key, start.elapsed().as_millis() as u64);
|
||||||
stat_encode_ms += enc_start.elapsed().as_millis() as u64;
|
stat_encode_ms += enc_start.elapsed().as_millis() as u64;
|
||||||
match r {
|
match r {
|
||||||
Ok(payload) => emit!(payload),
|
Ok(payload) => {
|
||||||
|
let kf = h264_contains_idr(&payload);
|
||||||
|
emit!(payload, kf)
|
||||||
|
}
|
||||||
Err(e) => eprintln!("[capture] nvenc encode failed: {e}"),
|
Err(e) => eprintln!("[capture] nvenc encode failed: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -410,7 +525,11 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
|
|||||||
let r = enc.encode(&yuv);
|
let r = enc.encode(&yuv);
|
||||||
stat_encode_ms += enc_start.elapsed().as_millis() as u64;
|
stat_encode_ms += enc_start.elapsed().as_millis() as u64;
|
||||||
match r {
|
match r {
|
||||||
Ok(ebs) => emit!(ebs.to_vec()),
|
Ok(ebs) => {
|
||||||
|
let payload = ebs.to_vec();
|
||||||
|
let kf = h264_contains_idr(&payload);
|
||||||
|
emit!(payload, kf)
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[capture] encode failed: {e}");
|
eprintln!("[capture] encode failed: {e}");
|
||||||
}
|
}
|
||||||
@ -442,25 +561,80 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
|
|||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
// 桌面静止:低频重发(丢包后恢复 + 会话保活)。
|
// 桌面静止:低频重发(丢包后恢复 + 会话保活)。
|
||||||
// 关键:KeyFrameReq 在桌面静止时也必须响应 —— 此前只在新帧路径
|
//
|
||||||
// 消费该标志,桌面完全静止时解码端请求 IDR 永远得不到响应 → 黑屏。
|
// ⚠️ 这里曾有三个缺陷,全部围绕「KeyFrameReq 被静默丢弃」:
|
||||||
|
// 1. `swap` 写在时间闸**外面**:请求若落在距上次发送 500ms 之内,
|
||||||
|
// 标志已被消费但分支不进入,该次请求直接丢失(手册 §6.1 第 3 条)。
|
||||||
|
// 2. 软编分支只重发旧码流、不看 force_key:永远吐不出 IDR(手册 §6.1 第 2 条)。
|
||||||
|
// 3. NVENC 分支虽然把 force_key 传给了编码器,但受第 1 条限制。
|
||||||
|
//
|
||||||
|
// 现在的语义:**关键帧请求绕过 500ms 节流**——它是错误恢复路径,
|
||||||
|
// 延迟一次 IDR 的代价是解码端持续黑屏,远高于多发一帧的带宽成本。
|
||||||
let force_key = keyreq.swap(false, Ordering::Relaxed);
|
let force_key = keyreq.swap(false, Ordering::Relaxed);
|
||||||
if last_sent.elapsed() > Duration::from_millis(500) {
|
let due = force_key || last_sent.elapsed() > Duration::from_millis(500);
|
||||||
|
if due {
|
||||||
match &mut encoder {
|
match &mut encoder {
|
||||||
EncoderKind::Nvenc(enc) => {
|
EncoderKind::Nvenc(enc) => {
|
||||||
// 重编当前 GPU 帧(内容未变,输出极小;force_key 时为 IDR)
|
// 重编当前 GPU 帧(内容未变,输出极小;force_key 时为 IDR)
|
||||||
let r = enc.encode(force_key, start.elapsed().as_millis() as u64);
|
let r = enc.encode(force_key, start.elapsed().as_millis() as u64);
|
||||||
match r {
|
match r {
|
||||||
Ok(payload) => emit!(payload),
|
Ok(payload) => {
|
||||||
|
let kf = h264_contains_idr(&payload);
|
||||||
|
emit!(payload, kf)
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[capture] nvenc idle encode failed: {e}")
|
eprintln!("[capture] nvenc idle encode failed: {e}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EncoderKind::Soft(_) => {
|
EncoderKind::Soft(enc) => {
|
||||||
// 软编:直接重发最后一帧码流副本
|
// 软编:静止桌面没有新像素可编,只能重发最近一帧。
|
||||||
if let Some(f) = last_frame.as_ref() {
|
// 但 force_key 时必须真的重编出 IDR —— 重发旧 P 帧对
|
||||||
let _ = tx.send(Some(f.clone()));
|
// 已经解不动的解码端毫无帮助。
|
||||||
|
//
|
||||||
|
// 裸像素来源:staging 纹理里仍是上一帧内容(DXGI 只在
|
||||||
|
// 有新帧时覆盖),重新 Map 一次即可,不必每帧额外拷 14MB。
|
||||||
|
if force_key {
|
||||||
|
enc.force_intra_frame();
|
||||||
|
let dims = (dup.width as usize, dup.height as usize);
|
||||||
|
let conv = dup.with_mapped(|src, pitch| {
|
||||||
|
if pitch == dims.0 * 4 {
|
||||||
|
YUVBuffer::from_bgra8_source(BgraSliceU8::new(
|
||||||
|
src, dims,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
scratch.resize(dims.0 * dims.1 * 4, 0);
|
||||||
|
for (row, dst) in
|
||||||
|
scratch.chunks_exact_mut(dims.0 * 4).enumerate()
|
||||||
|
{
|
||||||
|
dst.copy_from_slice(
|
||||||
|
&src[row * pitch..row * pitch + dims.0 * 4],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
YUVBuffer::from_bgra8_source(BgraSliceU8::new(
|
||||||
|
&scratch, dims,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
match conv {
|
||||||
|
Ok(yuv) => match enc.encode(&yuv) {
|
||||||
|
Ok(ebs) => {
|
||||||
|
let payload = ebs.to_vec();
|
||||||
|
let kf = h264_contains_idr(&payload);
|
||||||
|
emit!(payload, kf)
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!(
|
||||||
|
"[capture] idle keyframe encode failed: {e}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[capture] idle keyframe convert failed: {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(p) = last_payload.as_ref() {
|
||||||
|
// 非关键帧的保活重发:复用载荷但重建帧头(seq 必须递增)
|
||||||
|
let kf = last_kf;
|
||||||
|
emit!(p.clone(), kf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -478,3 +652,61 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
|
|||||||
std::thread::sleep(Duration::from_millis(300));
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::fold_cursor;
|
||||||
|
|
||||||
|
const W: i32 = 1920;
|
||||||
|
const H: i32 = 1080;
|
||||||
|
|
||||||
|
/// 界内可见 → 原样返回。中心与 (0,0) 都要覆盖:(0,0) 与 None 在协议里是两种语义,
|
||||||
|
/// 若实现把「左上角」误折叠成 None,光标到左上角就「消失」了(这是最容易写错的一条)。
|
||||||
|
#[test]
|
||||||
|
fn inside_bounds_pass_through_including_origin() {
|
||||||
|
assert_eq!(fold_cursor(true, 960, 540, W, H), (Some((960, 540)), false));
|
||||||
|
assert_eq!(fold_cursor(true, 0, 0, W, H), (Some((0, 0)), false));
|
||||||
|
assert_eq!(
|
||||||
|
fold_cursor(true, W - 1, H - 1, W, H),
|
||||||
|
(Some((1919, 1079)), false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏/触控抑制(CURSOR_SHOWING 未置位)→ None,**即使坐标界内**。
|
||||||
|
/// 顺序很关键:坐标合法但不可见时也不能画标记。
|
||||||
|
#[test]
|
||||||
|
fn hidden_cursor_folds_to_none_even_when_in_bounds() {
|
||||||
|
assert_eq!(fold_cursor(false, 100, 100, W, H), (None, false));
|
||||||
|
assert_eq!(fold_cursor(false, 0, 0, W, H), (None, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 越出采集画面 → None:负坐标(副屏在左,本机实测 -1920..-1)、
|
||||||
|
/// 右/下边界外、以及**恰好在边界上**(坐标 w 对应第 w+1 列,画面里没有)。
|
||||||
|
#[test]
|
||||||
|
fn out_of_capture_bounds_folds_to_none() {
|
||||||
|
assert_eq!(fold_cursor(true, -960, 500, W, H), (None, false)); // 副屏(负 X)
|
||||||
|
assert_eq!(fold_cursor(true, 100, -1, W, H), (None, false)); // 负 Y
|
||||||
|
assert_eq!(fold_cursor(true, W, 500, W, H), (None, false)); // 右边界外一列
|
||||||
|
assert_eq!(fold_cursor(true, 500, H, W, H), (None, false)); // 下边界外一行
|
||||||
|
assert_eq!(fold_cursor(true, 2610, 1300, W, H), (None, false)); // 主屏外右侧
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 越出 i16 正范围 → None + 第二返回 true(调用方据此打日志)。
|
||||||
|
/// 注意:常规采集尺寸(≤1920)下越界 x 永远先被边界检查拦下,i16 分支不可达——
|
||||||
|
/// 必须用超宽采集(假想带鱼屏)才能测到;它证明「界内但超 i16」会被折叠
|
||||||
|
/// 而**不是截断成看似合法的错坐标**。i16::MAX 恰好合法,不误报。
|
||||||
|
#[test]
|
||||||
|
fn beyond_i16_folds_with_overflow_flag() {
|
||||||
|
let huge_w = 40_000; // 假想的超宽采集,越过 i16::MAX
|
||||||
|
assert_eq!(
|
||||||
|
fold_cursor(true, i16::MAX as i32, 0, huge_w, H),
|
||||||
|
(Some((i16::MAX, 0)), false),
|
||||||
|
"i16::MAX 恰好界内合法,不该误报 overflow"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fold_cursor(true, 33_000, 10, huge_w, H),
|
||||||
|
(None, true),
|
||||||
|
"界内但超 i16 → overflow=true 的 None,而非截断成错误坐标"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
//! 输入注入:SendInput 实现(鼠标归一化绝对坐标 + 扫描码键盘)。
|
//! 输入注入:SendInput 实现(鼠标归一化绝对坐标 + 扫描码键盘 + v3 文本注入)。
|
||||||
|
|
||||||
use common::{InputEvent, MouseButton};
|
use common::{InputEvent, MouseButton};
|
||||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||||
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY,
|
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY,
|
||||||
KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_MIDDLEDOWN,
|
KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, KEYEVENTF_UNICODE, MOUSEEVENTF_ABSOLUTE,
|
||||||
MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP,
|
MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN,
|
||||||
MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, MOUSEINPUT, VIRTUAL_KEY, MOUSE_EVENT_FLAGS,
|
MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, MOUSEINPUT, VIRTUAL_KEY,
|
||||||
|
MOUSE_EVENT_FLAGS,
|
||||||
};
|
};
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
|
GetCursorPos, GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_CXSCREEN,
|
||||||
SM_YVIRTUALSCREEN,
|
SM_CYSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
|
||||||
};
|
};
|
||||||
|
use windows::Win32::Foundation::POINT;
|
||||||
|
|
||||||
// windows 0.58 未导出 LEFT 常量,按 WinUser.h 原值定义
|
// windows 0.58 未导出 LEFT 常量,按 WinUser.h 原值定义
|
||||||
const MOUSEEVENTF_LEFTDOWN: u32 = 0x0002;
|
const MOUSEEVENTF_LEFTDOWN: u32 = 0x0002;
|
||||||
@ -35,6 +37,62 @@ fn send_mouse(flags: u32, dx: i32, dy: i32, data: u32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 主显示器物理尺寸(像素)。相对移动的 clamp 上界必须与**采集空间**一致
|
||||||
|
/// (capture 用 `EnumOutputs(0)`;AuthOk 下发的 width/height 也来自它)。
|
||||||
|
fn output_size() -> (i32, i32) {
|
||||||
|
unsafe {
|
||||||
|
(
|
||||||
|
GetSystemMetrics(SM_CXSCREEN),
|
||||||
|
GetSystemMetrics(SM_CYSCREEN),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v4 相对鼠标:把远端指针从当前位置移动 `(dx, dy)` 像素。
|
||||||
|
///
|
||||||
|
/// **为什么不用 `SendInput(MOUSEEVENTF_MOVE)`**(协议 v4 的语义最容易在这里跑偏):
|
||||||
|
/// 实测(2026-10-31,见个人层 `docs/output/2026-10-31-3b-开工前实测.md`)该路径的
|
||||||
|
/// `dx` 不是像素,而是「mickey」,会被两件事放大:
|
||||||
|
/// 1. **指针加速**(「提高指针精确度」)。本机 `SPI_GETMOUSE=6/10/1` 下
|
||||||
|
/// dx=20 实际走 41px、dx=100 走 309px,且倍率是非线性的;
|
||||||
|
/// 2. **DPI 缩放**。关掉加速后倍率恒为 1.25 = 本机 125% 缩放。
|
||||||
|
/// 两者都随用户系统设置与机器变化,无法标定,因此**不能**把协议里的像素增量直接透传。
|
||||||
|
///
|
||||||
|
/// 改为「读光标 → 加增量 → clamp → 绝对注入」:
|
||||||
|
/// - 语义仍是**相对**(主控端只说移动多少,不说去哪里),远端指针始终权威;
|
||||||
|
/// - 实测 1:1 精确(dx=±1/2/3/5/17/100 四次重复全部等于请求值);
|
||||||
|
/// - 撞边后回滑立即响应,不积累「步数债」(clamp 丢掉的部分不回补)。
|
||||||
|
///
|
||||||
|
/// 已知限制(不隐藏):读光标与注入之间若用户同时移动真鼠标,会有一次增量被叠加。
|
||||||
|
/// 本机联调场景可接受;彻底消除需要 `SendInput` 的 mickey 路径,而那条路不可标定。
|
||||||
|
fn send_mouse_rel(dx: i16, dy: i16) {
|
||||||
|
if dx == 0 && dy == 0 {
|
||||||
|
return; // 零位移没有注入语义,也不该产生一次系统调用
|
||||||
|
}
|
||||||
|
let (w, h) = output_size();
|
||||||
|
if w <= 1 || h <= 1 {
|
||||||
|
eprintln!("[input] 相对移动失败:输出尺寸异常 {w}x{h}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut cur = POINT::default();
|
||||||
|
// GetCursorPos 失败时不能猜一个坐标继续注入——那会把指针扔到屏幕角落
|
||||||
|
if let Err(e) = unsafe { GetCursorPos(&mut cur) } {
|
||||||
|
eprintln!("[input] GetCursorPos 失败,相对移动被丢弃: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tx = (cur.x + dx as i32).clamp(0, w - 1);
|
||||||
|
let ty = (cur.y + dy as i32).clamp(0, h - 1);
|
||||||
|
// 归一化到 0..=65535(绝对注入空间 = 主显示器,不带 VIRTUALDESK,见 MouseMove 分支)
|
||||||
|
let nx = ((tx as i64 * 65535 + (w as i64 - 1) / 2) / (w as i64 - 1)) as i32;
|
||||||
|
let ny = ((ty as i64 * 65535 + (h as i64 - 1) / 2) / (h as i64 - 1)) as i32;
|
||||||
|
send_mouse(
|
||||||
|
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_MOVE.0,
|
||||||
|
nx,
|
||||||
|
ny,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn send_key(scancode: u16, extended: bool, up: bool) {
|
fn send_key(scancode: u16, extended: bool, up: bool) {
|
||||||
let mut flags = KEYEVENTF_SCANCODE;
|
let mut flags = KEYEVENTF_SCANCODE;
|
||||||
if extended {
|
if extended {
|
||||||
@ -73,12 +131,79 @@ pub fn virtual_desktop() -> (i32, i32, i32, i32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v3 文本注入:把一段 UTF-16 code unit 作为键盘事件送入前台应用。
|
||||||
|
///
|
||||||
|
/// 语义来源(实读,不凭记忆):
|
||||||
|
/// [KEYBDINPUT (winuser.h)](https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-keybdinput)
|
||||||
|
/// - `KEYEVENTF_UNICODE`(0x0004):`wVk` **必须为 0**,`wScan` 即要送出的 Unicode 字符;
|
||||||
|
/// 系统合成 `VK_PACKET` 按键事件,经 `TranslateMessage` 转为带该字符的 `WM_CHAR`。
|
||||||
|
/// - 文档原文是 "This flag can only be combined with the KEYEVENTF_KEYUP flag"。
|
||||||
|
/// 本项目**仍先发不带 KEYUP 的 down**(与现有 `send_key` 的 down/up 成对形态一致),
|
||||||
|
/// 把那句读作「不能与 SCANCODE/EXTENDEDKEY 混用」是**我的推断,文档没有明说**。
|
||||||
|
/// 若真机验收发现 down 事件被系统丢弃(表现为一个字符进两次或不进),
|
||||||
|
/// 退路是只发 `KEYEVENTF_UNICODE | KEYEVENTF_KEYUP` 单事件——改一处 `mk` 即可。
|
||||||
|
///
|
||||||
|
/// 为什么一次性批量提交(而非逐字符一对 down/up):
|
||||||
|
/// 代理对(U+1F600 = D83D DE00)的两个 code unit 必须在输入队列里**相邻**,
|
||||||
|
/// 远端才会把它们合成成一个字符。逐字符成对(down,up,down,up)会在两个
|
||||||
|
/// down 之间插入 up 事件;Windows 对 VK_PACKET 孤立代理项的缓冲规则文档
|
||||||
|
/// 没有描述,所以这里选择让两个 down 严格相邻的形态,由真机 emoji 验收(V7)定论。
|
||||||
|
///
|
||||||
|
/// 为什么是**一次** `SendInput` 调用(而不是 down 批 + up 批两次):
|
||||||
|
/// [Keyboard Input Overview](https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input)
|
||||||
|
/// 的 Simulating Input 一节明确——单调用注入的一批事件"系统确保不会被其他输入事件
|
||||||
|
/// 打散"(the system ensures that no other input events intermingle with the
|
||||||
|
/// simulated events),两次调用之间则可能插入真实键盘/鼠标事件,代理对就有被打散风险。
|
||||||
|
/// 于是把 N 个 down + N 个 up 拼成一个数组一次提交,顺序仍是全部 down 先于全部 up。
|
||||||
|
///
|
||||||
|
/// 上限由调用方(`common::clamp_text_units`,4096)保证;这里对空输入直接返回,
|
||||||
|
/// 因为 `SendInput(0)` 会返回 0(失败)且没有任何注入语义。
|
||||||
|
fn send_text(units: &[u16]) {
|
||||||
|
if units.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mk = |scan: u16, up: bool| INPUT {
|
||||||
|
r#type: INPUT_KEYBOARD,
|
||||||
|
Anonymous: INPUT_0 {
|
||||||
|
ki: KEYBDINPUT {
|
||||||
|
wVk: VIRTUAL_KEY(0), // KEYEVENTF_UNICODE 要求 wVk=0(官方文档)
|
||||||
|
wScan: scan,
|
||||||
|
dwFlags: if up {
|
||||||
|
KEYEVENTF_UNICODE | KEYEVENTF_KEYUP
|
||||||
|
} else {
|
||||||
|
KEYEVENTF_UNICODE
|
||||||
|
},
|
||||||
|
time: 0,
|
||||||
|
dwExtraInfo: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// 一次提交:全部 down,紧接全部 up。
|
||||||
|
let inputs: Vec<INPUT> = units
|
||||||
|
.iter()
|
||||||
|
.map(|&u| mk(u, false))
|
||||||
|
.chain(units.iter().map(|&u| mk(u, true)))
|
||||||
|
.collect();
|
||||||
|
let n = unsafe { SendInput(&inputs, std::mem::size_of::<INPUT>() as i32) };
|
||||||
|
// 可见失败后果:SendInput 返回「实际插入的事件数」,不等于请求数即失败
|
||||||
|
// (常见原因:桌面被 Secure UI 锁定、被 BlockInput 拦截)。
|
||||||
|
if n as usize != inputs.len() {
|
||||||
|
eprintln!("[input] SendInput 文本注入不完整: {n}/{}", inputs.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn handle(ev: InputEvent) {
|
pub fn handle(ev: InputEvent) {
|
||||||
match ev {
|
match ev {
|
||||||
InputEvent::MouseMove { x, y } => {
|
InputEvent::MouseMove { x, y } => {
|
||||||
// 主控端已按虚拟桌面归一化,这里直接透传 0..65535
|
// 归一化坐标系 = **被采集的那个输出**(capture 用 EnumOutputs(0),
|
||||||
|
// 即主屏;AuthOk 的 width/height 也是它)。
|
||||||
|
// MOUSEEVENTF_ABSOLUTE 不带 VIRTUALDESK 时,0..=65535 恰好映射到
|
||||||
|
// 主显示器——与采集空间一致。
|
||||||
|
// 曾经带 VIRTUALDESK:多显示器机器上注入空间变成整个虚拟桌面
|
||||||
|
// (比主屏宽),手机画面与指针位置系统性错位(2026-10-30 实测)。
|
||||||
|
// 单显示器时两者重合,所以桌面端从未暴露此问题。
|
||||||
send_mouse(
|
send_mouse(
|
||||||
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_VIRTUALDESK.0 | MOUSEEVENTF_MOVE.0,
|
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_MOVE.0,
|
||||||
x as i32,
|
x as i32,
|
||||||
y as i32,
|
y as i32,
|
||||||
0,
|
0,
|
||||||
@ -99,5 +224,19 @@ pub fn handle(ev: InputEvent) {
|
|||||||
}
|
}
|
||||||
InputEvent::KeyDown { scancode, extended } => send_key(scancode, extended, false),
|
InputEvent::KeyDown { scancode, extended } => send_key(scancode, extended, false),
|
||||||
InputEvent::KeyUp { scancode, extended } => send_key(scancode, extended, true),
|
InputEvent::KeyUp { scancode, extended } => send_key(scancode, extended, true),
|
||||||
|
InputEvent::MouseMoveRel { dx, dy } => send_mouse_rel(dx, dy),
|
||||||
|
InputEvent::Text { units } => {
|
||||||
|
// 上限截断必须可见(与 agent-core::dispatch_input 同口径;Windows 端
|
||||||
|
// 走自己的 handle(),不经过 agent-core,所以这里同源补齐——R-3 教训)。
|
||||||
|
let (units, truncated) = common::clamp_text_units(units);
|
||||||
|
if truncated {
|
||||||
|
eprintln!(
|
||||||
|
"[input] 文本超过上限,已截断到 {} 个 code unit",
|
||||||
|
common::MAX_TEXT_UNITS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 只打印长度:注入日志不得含用户输入内容(安全红线)
|
||||||
|
send_text(&units);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
//! 被控端 Agent:DXGI 桌面复制采集 → LZ4 压缩 → QUIC 下行视频;上行接收输入事件注入。
|
//! 被控端 Agent:DXGI 桌面复制采集 → H.264 编码 → QUIC 下行视频;上行接收输入事件注入。
|
||||||
//! P0 优化:视频帧经 watch 单槽通道(latest-wins,网络慢时只发最新帧,不积压);
|
//! P0 优化:视频帧经 watch 单槽通道(latest-wins,网络慢时只发最新帧,不积压);
|
||||||
//! 键鼠/控制走独立 QUIC 双向流,不与视频互相阻塞。
|
//! 键鼠/控制走独立 QUIC 双向流,不与视频互相阻塞。
|
||||||
|
//! 会话模型:**串行多会话**——accept 循环一次只服务一个主控,会话结束后回到 accept
|
||||||
|
//! 等下一个连接(进程不退出)。改成并发多会话需要先解决 DXGI 单实例独占,暂不做。
|
||||||
|
|
||||||
mod capture;
|
mod capture;
|
||||||
mod input;
|
mod input;
|
||||||
@ -10,6 +12,7 @@ use common::{read_control, write_control, ControlMsg};
|
|||||||
use rustls::pki_types::PrivatePkcs8KeyDer;
|
use rustls::pki_types::PrivatePkcs8KeyDer;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
|
|
||||||
@ -29,8 +32,60 @@ fn parse_args() -> Result<(u16, String)> {
|
|||||||
Ok((port, code))
|
Ok((port, code))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 显式声明进程 DPI 感知(**必须在任何采集/注入调用之前**,越早越好)。
|
||||||
|
///
|
||||||
|
/// 为什么不能省(3b 开工实测,见个人层 `docs/output/2026-10-31-3b-开工前实测.md` §4):
|
||||||
|
/// 相对鼠标走「`GetCursorPos` + 增量」,而 `GetCursorPos` 的坐标空间取决于进程的
|
||||||
|
/// DPI 感知模式——UNAWARE 进程拿到的是**逻辑坐标**(本机 125% 缩放下是 1536x864),
|
||||||
|
/// 而 DXGI 采集与 `AuthOk` 下发的是**物理像素**(1920x1080)。两者混用会让
|
||||||
|
/// clamp 上界偏小、增量尺度偏小:指针到不了屏幕右/下边缘,且移动距离只有 80%。
|
||||||
|
///
|
||||||
|
/// 为什么不能依赖现状:本机 `agent.exe` 实测确实是 `PER_MONITOR_AWARE(2)`,但那是
|
||||||
|
/// `HKCU\...\AppCompatFlags\Layers` 里一条**路径绑定**的 `HIGHDPIAWARE` 覆盖造成的
|
||||||
|
/// ——同一个 exe 复制到 `%TEMP%` 启动就变成 `UNAWARE(0)`。换机器、换构建路径、
|
||||||
|
/// CI 产物、用户机器上都不成立,所以必须由代码显式设置。
|
||||||
|
///
|
||||||
|
/// 失败时只告警不退出:DPI 感知不可用是老系统(< Win10 1703)的情况,
|
||||||
|
/// 此时相对鼠标会退化(精度下降),但绝对注入与其余功能不受影响——
|
||||||
|
/// 让整个被控端起不来是更差的选择。
|
||||||
|
fn init_dpi_awareness() {
|
||||||
|
use windows::Win32::UI::HiDpi::{
|
||||||
|
GetThreadDpiAwarenessContext, SetProcessDpiAwarenessContext, GetAwarenessFromDpiAwarenessContext,
|
||||||
|
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||||
|
};
|
||||||
|
match unsafe { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) } {
|
||||||
|
Ok(()) => println!("[agent] DPI awareness: PER_MONITOR_AWARE_V2 (set by this process)"),
|
||||||
|
Err(e) => {
|
||||||
|
// 失败有两种完全不同的原因,**必须分开报告**,否则日志会误导排查方向:
|
||||||
|
// a) 进程的感知模式已被别处设定(注册表 AppCompat 覆盖、清单、父进程)——
|
||||||
|
// ERROR_ACCESS_DENIED(0x80070005),此时若已经是 per-monitor 就完全没问题;
|
||||||
|
// b) 系统过旧不支持该 API——这才是真正的降级。
|
||||||
|
// 本机实测是 (a):HKCU 的 AppCompatFlags\Layers 里有一条路径绑定的 HIGHDPIAWARE。
|
||||||
|
let actual = unsafe {
|
||||||
|
GetAwarenessFromDpiAwarenessContext(GetThreadDpiAwarenessContext())
|
||||||
|
};
|
||||||
|
// DPI_AWARENESS(WinUser.h):INVALID=-1、UNAWARE=0、SYSTEM_AWARE=1、
|
||||||
|
// PER_MONITOR_AWARE=2。注意 PER_MONITOR 是 **2** 而不是 3。
|
||||||
|
let is_per_monitor = actual.0 == 2;
|
||||||
|
if is_per_monitor {
|
||||||
|
println!(
|
||||||
|
"[agent] DPI awareness: 已由外部设定为 PER_MONITOR_AWARE(无需本进程设置;{e})"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"[agent] 警告:DPI 感知为 {}(非 per-monitor),且本进程设置失败({e})。\
|
||||||
|
相对鼠标的位移比例会与被采集桌面不一致;绝对触摸不受影响。",
|
||||||
|
actual.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
// 第一件事:定下 DPI 感知,后续所有坐标运算都依赖它
|
||||||
|
init_dpi_awareness();
|
||||||
let (port, code) = parse_args()?;
|
let (port, code) = parse_args()?;
|
||||||
rustls::crypto::ring::default_provider()
|
rustls::crypto::ring::default_provider()
|
||||||
.install_default()
|
.install_default()
|
||||||
@ -50,6 +105,14 @@ async fn main() -> Result<()> {
|
|||||||
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
|
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
|
||||||
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
|
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
|
||||||
transport.send_window(64 * 1024);
|
transport.send_window(64 * 1024);
|
||||||
|
// 空闲超时:主控端被强杀(非正常 close)时收不到它的 Ping,连接会一直占住
|
||||||
|
// 串行 accept,下一个连接要等几十秒才能建立。10s = 20 个 Ping 周期,
|
||||||
|
// 正常会话(Ping 每 500ms)永不触发,只用来回收死连接。
|
||||||
|
transport.max_idle_timeout(Some(
|
||||||
|
Duration::from_secs(10)
|
||||||
|
.try_into()
|
||||||
|
.expect("10s fits in VarInt"),
|
||||||
|
));
|
||||||
let mut server_config = quinn::ServerConfig::with_single_cert(vec![cert], key.into())?;
|
let mut server_config = quinn::ServerConfig::with_single_cert(vec![cert], key.into())?;
|
||||||
server_config.transport_config(Arc::new(transport));
|
server_config.transport_config(Arc::new(transport));
|
||||||
|
|
||||||
@ -59,26 +122,74 @@ async fn main() -> Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
println!("[agent] listening 0.0.0.0:{port}, verify code = {code}");
|
println!("[agent] listening 0.0.0.0:{port}, verify code = {code}");
|
||||||
|
|
||||||
let conn = endpoint
|
// 串行多会话:一次只服务一个主控,结束后回到 accept。
|
||||||
.accept()
|
// 不做并发多会话——DXGI Desktop Duplication 每个输出仅允许一个活动实例,
|
||||||
.await
|
// 第二个 DuplicateOutput 会让第一个收到 ACCESS_LOST。
|
||||||
.ok_or_else(|| anyhow::anyhow!("endpoint closed"))?
|
// 会话失败(含认证失败)只记日志,不能让进程退出——否则每次连接都要重启被控端。
|
||||||
.await?;
|
loop {
|
||||||
println!("[agent] peer connected: {}", conn.remote_address());
|
let Some(incoming) = endpoint.accept().await else {
|
||||||
|
println!("[agent] endpoint closed, exit");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
match incoming.await {
|
||||||
|
Ok(conn) => {
|
||||||
|
println!("[agent] peer connected: {}", conn.remote_address());
|
||||||
|
if let Err(e) = serve_session(conn, &code).await {
|
||||||
|
println!("[agent] session ended: {e:#}");
|
||||||
|
}
|
||||||
|
println!("[agent] waiting for next connection");
|
||||||
|
}
|
||||||
|
Err(e) => println!("[agent] handshake failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单次会话:认证 → 采集/发送/输入 → 会话结束。
|
||||||
|
/// 返回 Err 表示会话异常结束(含认证失败),调用方继续 accept。
|
||||||
|
async fn serve_session(conn: quinn::Connection, code: &str) -> Result<()> {
|
||||||
// 流 1:认证 + 视频下行
|
// 流 1:认证 + 视频下行
|
||||||
let (mut send, mut recv) = conn.accept_bi().await?;
|
let (mut send, mut recv) = conn.accept_bi().await?;
|
||||||
|
|
||||||
// 认证
|
// 认证:先校验版本,再校验验证码。
|
||||||
|
// 顺序刻意如此——版本不一致时字段布局可能不同,验证码的解析结果不可信。
|
||||||
let first = read_control(&mut recv).await?;
|
let first = read_control(&mut recv).await?;
|
||||||
let code_ok = matches!(&first, ControlMsg::Auth { code: c } if *c == code);
|
let auth_err: Option<String> = match &first {
|
||||||
if !code_ok {
|
ControlMsg::Auth {
|
||||||
write_control(&mut send, &ControlMsg::AuthFail("bad code".into())).await?;
|
code: c,
|
||||||
|
proto_version,
|
||||||
|
} => {
|
||||||
|
if *proto_version != common::PROTO_VERSION {
|
||||||
|
Some(format!(
|
||||||
|
"protocol version mismatch: client={proto_version}, server={}",
|
||||||
|
common::PROTO_VERSION
|
||||||
|
))
|
||||||
|
} else if *c != code {
|
||||||
|
Some("bad code".into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => Some(format!("expected Auth, got {other:?}")),
|
||||||
|
};
|
||||||
|
if let Some(msg) = auth_err {
|
||||||
|
write_control(&mut send, &ControlMsg::AuthFail(msg.clone())).await?;
|
||||||
send.finish()?;
|
send.finish()?;
|
||||||
anyhow::bail!("auth failed");
|
// 必须等对端读到 AuthFail 再关连接:直接返回会让进程带走 QUIC 缓冲区里
|
||||||
|
// 尚未发出的数据,客户端只会看到 "connection lost" 而不是 AuthFail。
|
||||||
|
// (agent-core 已有同款修复,此处同源补齐——手册 §6.1 第 1 条)
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(5), conn.closed()).await;
|
||||||
|
anyhow::bail!(msg);
|
||||||
}
|
}
|
||||||
let (w, h) = capture::desktop_size()?;
|
let (w, h) = capture::desktop_size()?;
|
||||||
write_control(&mut send, &ControlMsg::AuthOk { width: w, height: h }).await?;
|
write_control(
|
||||||
|
&mut send,
|
||||||
|
&ControlMsg::AuthOk {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
proto_version: common::PROTO_VERSION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
println!("[agent] auth ok, desktop {w}x{h}");
|
println!("[agent] auth ok, desktop {w}x{h}");
|
||||||
|
|
||||||
// 流 2:键鼠输入 + 控制上行(与视频分离,避免被视频积压阻塞)
|
// 流 2:键鼠输入 + 控制上行(与视频分离,避免被视频积压阻塞)
|
||||||
@ -91,7 +202,14 @@ async fn main() -> Result<()> {
|
|||||||
// 拥塞反馈标志:发送侧 write 超过单帧预算(20ms) → 置位,采集线程跳帧降注入率
|
// 拥塞反馈标志:发送侧 write 超过单帧预算(20ms) → 置位,采集线程跳帧降注入率
|
||||||
let congestion = Arc::new(AtomicBool::new(false));
|
let congestion = Arc::new(AtomicBool::new(false));
|
||||||
let congestion_capture = congestion.clone();
|
let congestion_capture = congestion.clone();
|
||||||
std::thread::spawn(move || capture::run(tx, keyreq_capture, congestion_capture));
|
// 采集线程退出信号:会话结束必须等它真正退出才能 accept 下一个连接——
|
||||||
|
// DXGI Desktop Duplication 每个输出只允许一个活动实例,旧线程还持有 duplication
|
||||||
|
// 时新线程的 DuplicateOutput 会让旧实例收到 ACCESS_LOST(表现为下一个会话黑屏)。
|
||||||
|
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
capture::run(tx, keyreq_capture, congestion_capture);
|
||||||
|
let _ = done_tx.send(());
|
||||||
|
});
|
||||||
|
|
||||||
// 上行控制消息(Pong)经通道交给主循环写流 2,避免多任务争抢
|
// 上行控制消息(Pong)经通道交给主循环写流 2,避免多任务争抢
|
||||||
let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>();
|
let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>();
|
||||||
@ -156,6 +274,11 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader.abort();
|
reader.abort();
|
||||||
|
// 关掉流并丢弃接收端,让采集线程下一次 send 失败而退出
|
||||||
|
let _ = send.finish();
|
||||||
|
drop(frames_rx);
|
||||||
|
// 等采集线程真正退出后再返回,调用方才会 accept 下一个连接(DXGI 独占,见上)
|
||||||
|
let _ = tokio::task::spawn_blocking(move || done_rx.recv_timeout(Duration::from_secs(2))).await;
|
||||||
println!("[agent] session closed");
|
println!("[agent] session closed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
20
android-ffi/Cargo.toml
Normal file
20
android-ffi/Cargo.toml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "android-ffi"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# 安卓主控的 JNI 薄层:把 viewer-core 的异步 Session 包成 Kotlin 可同步调用的句柄。
|
||||||
|
# 2026-10-29 裁决变更:原方案 A2 选 UniFFI,但其实训运行时不在 Maven Central /
|
||||||
|
# Mozilla maven(查证 404),需自编译发布,成本超过收益。改为裸 JNI——阶段 0
|
||||||
|
# (D:\working\android\phase0)已验证该链路真机可跑。接口刻意保持窄(连接/取帧/
|
||||||
|
# 发输入/销毁),将来若引入 UniFFI 也只是替换本层。
|
||||||
|
[lib]
|
||||||
|
name = "androidffi"
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
viewer-core = { path = "../viewer-core" }
|
||||||
|
common = { path = "../common" }
|
||||||
|
anyhow.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
jni = "0.21"
|
||||||
478
android-ffi/src/lib.rs
Normal file
478
android-ffi/src/lib.rs
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
//! android-ffi:viewer-core 的 JNI 薄层(阶段 1)。
|
||||||
|
//!
|
||||||
|
//! 线程模型(决定接口形态的原因):
|
||||||
|
//! - Kotlin 解码线程调用 `nativeNextFrame` 同步取帧(内部 block_on,一次一帧);
|
||||||
|
//! - Kotlin UI 线程调用 `nativeSend*` 发输入——tokio unbounded channel 入队不阻塞;
|
||||||
|
//! - 共用一个 current-thread runtime:`block_on(next_frame)` 期间 runtime 仍在驱动
|
||||||
|
//! QUIC 收发与 Ping;输入入队只是 channel send,不需要 runtime 参与。
|
||||||
|
//!
|
||||||
|
//! **锁纪律**:`nativeNextFrame` 在等帧期间**不得持有**会话锁——桌面静止时帧间隔
|
||||||
|
//! 可达 500ms,持锁会让 UI 线程的输入调用同步卡住。做法:短暂持锁克隆
|
||||||
|
//! `Arc<Session>` 与 `&'static Runtime` 引用后立即释放,再 block_on。
|
||||||
|
//! 代价:block_on 期间 destroy 会 use-after-free——阶段 1 由 Kotlin 侧
|
||||||
|
//! 「先停解码线程、后 destroy」的顺序纪律保证(登记在方案风险清单)。
|
||||||
|
//!
|
||||||
|
//! 句柄约定:`nativeConnect` 返回 Box 裸指针(永不为 0)作为 jlong;
|
||||||
|
//! `nativeDestroy` 释放。句柄释放后不得再使用,Kotlin 侧用置 0 保护。
|
||||||
|
//!
|
||||||
|
//! 帧返回约定:byte[],布局 = [1 标志][8 seq LE][8 ts LE][4 w LE][4 h LE]
|
||||||
|
//! [1 has_cursor][2 cursorX i16 LE][2 cursorY i16 LE][payload]。
|
||||||
|
//! 定长头 30 字节(v5 起;此前 25);Kotlin 读 buf[0] 判断 keyframe(标志位复用为:
|
||||||
|
//! 0=P 帧, 1=IDR 帧,会话结束则整个返回 null)。
|
||||||
|
//! has_cursor=0 时后 4 字节恒为 0 且**无意义**(指针隐藏/离屏/后端未实现,
|
||||||
|
//! 语义单一来源在被控端——见 common::VideoFrameHeader::cursor);=1 时坐标为
|
||||||
|
//! 采集画面内物理像素(0 ≤ x < w、0 ≤ y < h,被控端已折叠越界值)。
|
||||||
|
|
||||||
|
use common::InputEvent;
|
||||||
|
use jni::objects::{JCharArray, JClass, JObject, JString};
|
||||||
|
use jni::sys::{jbyteArray, jint, jlong, jstring};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use viewer_core::{ConnectConfig, Session};
|
||||||
|
|
||||||
|
/// 帧头长度:keyframe 标志(1) + seq(8) + ts(8) + w(4) + h(4) + has_cursor(1) + x(2) + y(2)
|
||||||
|
/// (v5:25→30,尾部三字段是光标;放末尾保证 Kotlin 旧偏移量全部不移动)
|
||||||
|
const FRAME_HEADER_LEN: usize = 30;
|
||||||
|
|
||||||
|
/// 把一个帧的元数据打包成定长头字节(**不含载荷**)。
|
||||||
|
///
|
||||||
|
/// 独立成纯函数并被单测钉住的原因:Kotlin 侧 `FrameCodec` 按同样的偏移读这些字节,
|
||||||
|
/// 任一侧挪一位,两端都编译得过、真机也连得上,只是标记画错位置——静默失败。
|
||||||
|
/// 单测用**手写字面量**断言(不是"算出来的期望值"),这样 RustBridgeTest 的
|
||||||
|
/// golden bytes 与这里是同一组常数对拍:改了写入布局,两边至少一边必红。
|
||||||
|
fn pack_frame_header(f: &viewer_core::Frame) -> [u8; FRAME_HEADER_LEN] {
|
||||||
|
let mut head = [0u8; FRAME_HEADER_LEN];
|
||||||
|
head[0] = f.keyframe as u8;
|
||||||
|
head[1..9].copy_from_slice(&f.seq.to_le_bytes());
|
||||||
|
head[9..17].copy_from_slice(&f.ts_ms.to_le_bytes());
|
||||||
|
head[17..21].copy_from_slice(&f.width.to_le_bytes());
|
||||||
|
head[21..25].copy_from_slice(&f.height.to_le_bytes());
|
||||||
|
// v5 光标三字段(偏移 25/26/28):None → has=0 且坐标字节保持 0(无意义)。
|
||||||
|
// 用显式 match 而不是 `f.cursor.is_some() as u8`——两处读同一个 Option,
|
||||||
|
// 任何「Some 但忘写坐标」的编辑错误都会在这里暴露。
|
||||||
|
match f.cursor {
|
||||||
|
Some((x, y)) => {
|
||||||
|
head[25] = 1;
|
||||||
|
head[26..28].copy_from_slice(&x.to_le_bytes());
|
||||||
|
head[28..30].copy_from_slice(&y.to_le_bytes());
|
||||||
|
}
|
||||||
|
None => head[25] = 0,
|
||||||
|
}
|
||||||
|
head
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// 构造最小 Frame(载荷只放一字节,本测试只关心定长头)。
|
||||||
|
fn frame(seq: u64, cursor: Option<(i16, i16)>) -> viewer_core::Frame {
|
||||||
|
viewer_core::Frame {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
ts_ms: 0x0011_2233_4455_6677,
|
||||||
|
seq,
|
||||||
|
keyframe: true,
|
||||||
|
cursor,
|
||||||
|
payload: vec![0xAB],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **写端**字面量对拍:期望值逐字节手写,与 Kotlin 侧 RustBridgeTest 的
|
||||||
|
/// goldenHeader 是同一组常数。两侧各握一份手抄常数,改任何一侧的偏移,
|
||||||
|
/// 另一侧必红——这才是跨端契约的闭环(golden 测试只锁读端时有盲区:
|
||||||
|
/// 若 Kotlin 的字面量本身抄错了 Rust 布局,读端测试会跟着一起错还全绿)。
|
||||||
|
#[test]
|
||||||
|
fn header_bytes_match_the_declared_layout() {
|
||||||
|
let head = pack_frame_header(&frame(0x0102_0304_0506_0708, Some((960, 540))));
|
||||||
|
assert_eq!(
|
||||||
|
head,
|
||||||
|
[
|
||||||
|
0x01, // [0] keyframe=true
|
||||||
|
0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, // [1..9] seq=0x0102..0708 LE
|
||||||
|
0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00, // [9..17] ts LE
|
||||||
|
0x80, 0x07, 0x00, 0x00, // [17..21] w=1920=0x0780 LE
|
||||||
|
0x38, 0x04, 0x00, 0x00, // [21..25] h=1080=0x0438 LE
|
||||||
|
0x01, // [25] has_cursor
|
||||||
|
0xC0, 0x03, // [26..28] x=960=0x03C0 LE
|
||||||
|
0x1C, 0x02, // [28..30] y=540=0x021C LE
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// None 分支:has=0 且**坐标字节必须为 0**(vec 初始化保证),Kotlin 侧
|
||||||
|
/// 测试锁「has=0 时无条件 null」,这里锁「写端不残留脏坐标」。
|
||||||
|
#[test]
|
||||||
|
fn none_cursor_writes_zero_flag_and_zero_coords() {
|
||||||
|
let head = pack_frame_header(&frame(1, None));
|
||||||
|
assert_eq!(head[25], 0);
|
||||||
|
assert_eq!(&head[26..30], &[0u8; 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 负坐标(协议层不该到达,但写端要忠实 i16 位模式——符号截断是静默错位)。
|
||||||
|
#[test]
|
||||||
|
fn negative_coords_keep_bit_pattern() {
|
||||||
|
let head = pack_frame_header(&frame(1, Some((-1, -32768))));
|
||||||
|
assert_eq!(&head[26..28], &[0xFF, 0xFF]);
|
||||||
|
assert_eq!(&head[28..30], &[0x00, 0x80]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionBox {
|
||||||
|
session: Arc<Session>,
|
||||||
|
runtime: tokio::runtime::Runtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 连接被控端。成功返回句柄(>0),失败返回 0;错误消息经 `nativeLastError()` 取,
|
||||||
|
/// **错误分类**经 `nativeLastErrorKind()` 取(3a:界面按分类显示中文文案,原始英文作第二行)。
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeConnect<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
host: JString<'local>,
|
||||||
|
port: jint,
|
||||||
|
code: JString<'local>,
|
||||||
|
) -> jlong {
|
||||||
|
let host_s = match env.get_string(&host) {
|
||||||
|
Ok(s) => s.to_string_lossy().into_owned(),
|
||||||
|
Err(_) => {
|
||||||
|
set_last_error(-1, "host 参数不是合法字符串");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let code_s = match env.get_string(&code) {
|
||||||
|
Ok(s) => s.to_string_lossy().into_owned(),
|
||||||
|
Err(_) => {
|
||||||
|
set_last_error(-1, "code 参数不是合法字符串");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let built = std::panic::catch_unwind(|| {
|
||||||
|
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => return Err((-1i32, format!("runtime init: {e}"))),
|
||||||
|
};
|
||||||
|
let cfg = ConnectConfig::direct(host_s, port as u16, code_s);
|
||||||
|
match runtime.block_on(Session::connect(cfg)) {
|
||||||
|
Ok(session) => Ok(SessionBox {
|
||||||
|
session: Arc::new(session),
|
||||||
|
runtime,
|
||||||
|
}),
|
||||||
|
// ConnectError 的 Display 即原始 detail;kind.code() 与
|
||||||
|
// viewer_core::ConnectErrorKind::code 的映射表一致(FFI 契约)。
|
||||||
|
Err(e) => Err((e.kind.code(), e.detail)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match built {
|
||||||
|
Ok(Ok(boxed)) => {
|
||||||
|
set_last_error(0, "");
|
||||||
|
Box::into_raw(Box::new(Mutex::new(boxed))) as jlong
|
||||||
|
}
|
||||||
|
Ok(Err((kind, msg))) => {
|
||||||
|
set_last_error(kind, &msg);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_last_error(-1, "panic in nativeConnect");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// kind = 0 表示清除;-1 表示 FFI 层自身错误(无 viewer-core 分类,Kotlin 走 Other 文案)。
|
||||||
|
fn set_last_error(kind: i32, msg: &str) {
|
||||||
|
let mut slot = LAST_ERROR.lock().unwrap();
|
||||||
|
if msg.is_empty() {
|
||||||
|
*slot = None;
|
||||||
|
} else {
|
||||||
|
*slot = Some((kind, msg.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static LAST_ERROR: Mutex<Option<(i32, String)>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// 取最近一次 nativeConnect 的错误消息(无错误返回 null)。
|
||||||
|
/// FFI 契约:调用方必须先 `nativeLastErrorKind()` 再 `nativeLastError()`——
|
||||||
|
/// 本函数读走消息时**清空整个槽位**(kind 一起失效),颠倒顺序会拿到 kind=0。
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeLastError<'local>(
|
||||||
|
env: JNIEnv<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
) -> jstring {
|
||||||
|
let mut slot = LAST_ERROR.lock().unwrap();
|
||||||
|
let msg = slot.as_ref().map(|(_, m)| m.clone());
|
||||||
|
match msg {
|
||||||
|
Some(m) => {
|
||||||
|
*slot = None;
|
||||||
|
env.new_string(m)
|
||||||
|
.map(|s| s.into_raw())
|
||||||
|
.unwrap_or(std::ptr::null_mut())
|
||||||
|
}
|
||||||
|
None => std::ptr::null_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取最近一次 nativeConnect 的错误分类码(viewer_core::ConnectErrorKind::code)。
|
||||||
|
/// 只读不清空;0 = 无错误,-1 = FFI 层自身错误(参数非法/panic/runtime 初始化失败,
|
||||||
|
/// 无 viewer-core 分类,Kotlin 按 Other 文案 + 原始消息显示)。
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeLastErrorKind(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
) -> jint {
|
||||||
|
let slot = LAST_ERROR.lock().unwrap();
|
||||||
|
slot.as_ref().map(|(k, _)| *k).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取下一帧(阻塞直到有帧或会话结束)。返回 null = 会话结束。
|
||||||
|
/// 仅允许解码线程调用;destroy 前必须先停止本线程(见文件头锁纪律)。
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeNextFrame<'local>(
|
||||||
|
env: JNIEnv<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
handle: jlong,
|
||||||
|
) -> jbyteArray {
|
||||||
|
// 短暂持锁:克隆 Arc 与拿 runtime 引用,随后立即释放——等帧不能挡输入。
|
||||||
|
let (session, runtime) = {
|
||||||
|
let Some(boxed) = handle_to_box(handle) else {
|
||||||
|
return std::ptr::null_mut();
|
||||||
|
};
|
||||||
|
let guard = lock_box(boxed);
|
||||||
|
(guard.session.clone(), &guard.runtime as *const tokio::runtime::Runtime)
|
||||||
|
};
|
||||||
|
// SAFETY: 见文件头「锁纪律」。Kotlin 保证解码线程存活期间不 destroy。
|
||||||
|
let rt = unsafe { &*runtime };
|
||||||
|
|
||||||
|
match rt.block_on(session.next_frame()) {
|
||||||
|
None => std::ptr::null_mut(),
|
||||||
|
Some(f) => {
|
||||||
|
// 布局单一真源是 pack_frame_header(那里有字面量对拍单测)
|
||||||
|
let head = pack_frame_header(&f);
|
||||||
|
let mut buf = Vec::with_capacity(FRAME_HEADER_LEN + f.payload.len());
|
||||||
|
buf.extend_from_slice(head.as_slice());
|
||||||
|
buf.extend_from_slice(&f.payload);
|
||||||
|
match env.byte_array_from_slice(&buf) {
|
||||||
|
Ok(a) => a.into_raw(),
|
||||||
|
Err(_) => std::ptr::null_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话统计打包:高 32 位 rtt_ms,低 32 位 frames_in 低 32 位。-1 = 句柄无效。
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeStats(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
) -> jlong {
|
||||||
|
let Some(boxed) = handle_to_box(handle) else {
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
let guard = lock_box(boxed);
|
||||||
|
let st = guard.session.stats();
|
||||||
|
// 打包进 i64:高 32 位 rtt_ms、低 32 位 frames_in,Kotlin 侧移位还原
|
||||||
|
(((st.rtt_ms & 0xFFFF_FFFF) << 32) | (st.frames_in & 0xFFFF_FFFF)) as jlong
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 被控端桌面尺寸,打包成 i64:高 32 位宽、低 32 位高。0 = 句柄无效。
|
||||||
|
/// Kotlin 侧必须在建解码器之前调用,用返回值定 SurfaceView 宽高比。
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeDesktopSize(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
) -> jlong {
|
||||||
|
let Some(boxed) = handle_to_box(handle) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let guard = lock_box(boxed);
|
||||||
|
let (w, h) = guard.session.desktop_size();
|
||||||
|
(((w as u64 & 0xFFFF_FFFF) << 32) | (h as u64 & 0xFFFF_FFFF)) as jlong
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseMove(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
x: jint,
|
||||||
|
y: jint,
|
||||||
|
) {
|
||||||
|
with_session(handle, |s| {
|
||||||
|
s.send_input(InputEvent::MouseMove {
|
||||||
|
x: x.clamp(0, 65535) as u16,
|
||||||
|
y: y.clamp(0, 65535) as u16,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseRel(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
dx: jint,
|
||||||
|
dy: jint,
|
||||||
|
) {
|
||||||
|
// 协议 v4 的字段是 i16:Kotlin 侧传 Int,越界一律 clamp 而不是静默回绕。
|
||||||
|
// 越界值只可能来自主控端 bug 或恶意对端;clamp 让行为有界且可见(不 panic、不 UB)。
|
||||||
|
let dx = dx.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
|
||||||
|
let dy = dy.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
|
||||||
|
with_session(handle, |s| s.send_mouse_rel(dx, dy));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// button: 0=左 1=右 其他=中;down: 1=按下 0=抬起
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseButton(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
button: jint,
|
||||||
|
down: jint,
|
||||||
|
) {
|
||||||
|
use common::MouseButton;
|
||||||
|
let b = match button {
|
||||||
|
0 => MouseButton::Left,
|
||||||
|
1 => MouseButton::Right,
|
||||||
|
_ => MouseButton::Middle,
|
||||||
|
};
|
||||||
|
with_session(handle, |s| {
|
||||||
|
s.send_input(if down != 0 {
|
||||||
|
InputEvent::MouseDown(b)
|
||||||
|
} else {
|
||||||
|
InputEvent::MouseUp(b)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendWheel(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
dy: jint,
|
||||||
|
) {
|
||||||
|
with_session(handle, |s| s.send_input(InputEvent::Wheel { dy }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendKey(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
scancode: jint,
|
||||||
|
extended: jint,
|
||||||
|
down: jint,
|
||||||
|
) {
|
||||||
|
with_session(handle, |s| {
|
||||||
|
s.send_input(if down != 0 {
|
||||||
|
InputEvent::KeyDown {
|
||||||
|
scancode: scancode as u16,
|
||||||
|
extended: extended != 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
InputEvent::KeyUp {
|
||||||
|
scancode: scancode as u16,
|
||||||
|
extended: extended != 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 协议 v3:文本上行。`units` 是 Kotlin 侧的 `CharArray`(UTF-16 code unit)。
|
||||||
|
///
|
||||||
|
/// 为什么不用 `JString`:jni 0.21 的 `get_string` 走 Modified-UTF-8 → Rust `String`,
|
||||||
|
/// 落单代理项(IME 提交边界常见)会在这次往返里丢失。`char[]` 经
|
||||||
|
/// `GetCharArrayRegion` 按 u16 直接拷贝,零转换(`jchar = u16`,见 jni-sys-0.3.1/lib.rs:15)。
|
||||||
|
///
|
||||||
|
/// 截断与空输入的判断在 `viewer_core::Session::send_text` 里做,与被控端同一口径。
|
||||||
|
/// 失败必须有可见后果:JNI 取数失败走 eprintln(Android 上落 stderr→logcat),
|
||||||
|
/// 不静默返回成功——用户对着远端打字却没字,最坏的就是「看起来发送了」。
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendText<'local>(
|
||||||
|
// 不需要 `mut`:jni 0.21.1 的 `get_array_length` / `get_char_array_region`
|
||||||
|
// 都是 `&self`(jnienv.rs:1652 / :1887),与 `new_string` 之类的 `&mut self` 不同。
|
||||||
|
env: JNIEnv<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
handle: jlong,
|
||||||
|
units: JObject<'local>,
|
||||||
|
) {
|
||||||
|
if units.as_raw().is_null() {
|
||||||
|
// Kotlin 侧不会传 null,但 JNI 入口的对端数据一律不信任(全局安全规则)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let arr = JCharArray::from(units);
|
||||||
|
let len = match env.get_array_length(&arr) {
|
||||||
|
Ok(n) if n > 0 => n as usize,
|
||||||
|
Ok(_) => return,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[android-ffi] get_array_length failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut buf = vec![0u16; len];
|
||||||
|
if let Err(e) = env.get_char_array_region(&arr, 0, &mut buf) {
|
||||||
|
eprintln!("[android-ffi] get_char_array_region failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
with_session(handle, |s| s.send_text(&buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeRequestKeyframe(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
with_session(handle, |s| s.request_keyframe());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 释放会话。Kotlin 侧调用后必须把句柄置 0,且先停解码线程。
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeDestroy(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
if handle == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let real = handle as *mut Mutex<SessionBox>;
|
||||||
|
unsafe {
|
||||||
|
let _ = Box::from_raw(real);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_to_box(handle: jlong) -> Option<&'static Mutex<SessionBox>> {
|
||||||
|
if handle == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let real = handle as *mut Mutex<SessionBox>;
|
||||||
|
// 悬垂指针无法在 Rust 侧检测:阶段 1 由 Kotlin「destroy 后置 0 + 先停线程」
|
||||||
|
// 的顺序纪律保证。违反即 UB,登记在方案风险清单(阶段 2 可加代际表消除)。
|
||||||
|
// NonNull::new 是安全函数;解引用才是 unsafe 点
|
||||||
|
std::ptr::NonNull::new(real).map(|p| unsafe { p.as_ref() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 锁中毒容忍:SessionBox 内部无跨线程共享可变状态(Session 各方法内部自带
|
||||||
|
/// 并发原语),中毒后继续用是安全的。
|
||||||
|
fn lock_box(b: &Mutex<SessionBox>) -> std::sync::MutexGuard<'_, SessionBox> {
|
||||||
|
match b.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_session(handle: jlong, f: impl FnOnce(&Session)) {
|
||||||
|
let Some(boxed) = handle_to_box(handle) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let guard = lock_box(boxed);
|
||||||
|
f(&guard.session);
|
||||||
|
}
|
||||||
15
apps/android-viewer/.gitignore
vendored
Normal file
15
apps/android-viewer/.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Gradle / Android 构建产物
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Rust 交叉编译产物(由 build-android.ps1 生成到 app/libs)
|
||||||
|
app/libs/
|
||||||
|
|
||||||
|
# 验证截图
|
||||||
|
evidence/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
67
apps/android-viewer/app/build.gradle.kts
Normal file
67
apps/android-viewer/app/build.gradle.kts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.verify.remote"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.verify.remote"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 5
|
||||||
|
versionName = "0.1-phase4b"
|
||||||
|
// 阶段 1 只测真机 arm64;x86_64 留给模拟器回归
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3a/U7:安卓测试基础设施(Q3=A,用户 2026-10-31 批准)。
|
||||||
|
// **仅测试作用域**:junit 不进 APK,runtime dependencies 保持为空。
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
// android.jar 桩方法抛 "not mocked"——本工程的单测刻意只测纯逻辑
|
||||||
|
// (不构造 Android 对象),此开关只兜常量引用类的类加载副作用。
|
||||||
|
isReturnDefaultValues = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "11"
|
||||||
|
}
|
||||||
|
|
||||||
|
// .so 由外部脚本(build-android.ps1)预先拷到 app/libs/arm64-v8a/,
|
||||||
|
// 不让 Gradle 调 cargo——构建职责分离:Rust 侧失败与 Gradle 侧失败可立刻区分。
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
jniLibs.srcDirs("libs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
// AGP 默认已按 useLegacyPackaging 处理;保持默认压缩+extractNativeLibs=false
|
||||||
|
// 由 AGP 保证 STORED+对齐,手动链的坑(lessons 记录)在 Gradle 下不存在
|
||||||
|
useLegacyPackaging = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// 阶段 1 刻意零第三方 UI 依赖:纯 View 代码布局,减少变量。
|
||||||
|
// AndroidX 在阶段 2 做正式 UI 时再引入。
|
||||||
|
// 3a/U7(Q3=A 批准):仅 JUnit 测试作用域,不进 APK。
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
}
|
||||||
22
apps/android-viewer/app/src/main/AndroidManifest.xml
Normal file
22
apps/android-viewer/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- 阶段 1:局域网连 agent.exe 需要网络 -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="Remote Viewer"
|
||||||
|
android:hasCode="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:keepScreenOn="true"
|
||||||
|
android:windowSoftInputMode="adjustPan"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接失败分类 → 用户可执行的中文提示(阶段 3a,U8/N6 闭环)。
|
||||||
|
*
|
||||||
|
* 分类在 Rust 侧做(`viewer_core::ConnectErrorKind`),这里只做编号→文案映射。
|
||||||
|
* 编号契约与 Rust 的 `code()` 一一对应(`android-ffi/src/lib.rs` 文件头);
|
||||||
|
* Kotlin 不 import Rust 类型,跨 FFI 只传整数,所以本对象是纯逻辑、可 JVM 单测。
|
||||||
|
*
|
||||||
|
* 文案纪律:每条都必须**告诉用户下一步做什么**(方案 §3.4:不能停在「连接中」),
|
||||||
|
* 原始英文错误由 UI 作第二行展示(排障用,两者不互相替代)。
|
||||||
|
*/
|
||||||
|
object ConnectErrorText {
|
||||||
|
/** FFI 编号。与 viewer_core::ConnectErrorKind::code 同步,改动必须两侧一起改 */
|
||||||
|
const val KIND_NONE = 0
|
||||||
|
const val KIND_FFI_SELF = -1 // FFI 层自身错误(参数/panic/runtime),无业务分类
|
||||||
|
const val KIND_RESOLVE = 1
|
||||||
|
const val KIND_TIMEOUT = 2
|
||||||
|
const val KIND_REFUSED = 3
|
||||||
|
const val KIND_AUTH = 4
|
||||||
|
const val KIND_VERSION = 5
|
||||||
|
const val KIND_OTHER = 9
|
||||||
|
|
||||||
|
/** kind 未知(编号漂移)时返回 null,调用方必须落到带原始错误的兜底文案,不得静默 */
|
||||||
|
fun summary(kind: Int): String? = when (kind) {
|
||||||
|
KIND_RESOLVE -> "无法解析这个地址:检查 IP 是否写对"
|
||||||
|
KIND_TIMEOUT -> "连接超时:被控端不在线或入站被拦(检查 agent 是否在跑;本机联调需防火墙放行 47800-47810)"
|
||||||
|
KIND_REFUSED -> "被控端应答异常:确认这个端口上跑的是 agent"
|
||||||
|
KIND_AUTH -> "验证码错误:请向被控端核对验证码"
|
||||||
|
KIND_VERSION -> "协议版本不一致:两端都要更新到同一版本"
|
||||||
|
KIND_FFI_SELF, KIND_OTHER -> "连接失败"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整首行:有分类文案用分类,未知编号不猜语义 */
|
||||||
|
fun firstLine(kind: Int): String =
|
||||||
|
summary(kind) ?: "连接失败(未知错误类型 $kind)"
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 光标标记的**纯逻辑**:把协议帧头里的远端光标坐标(采集画面内物理像素)映射到
|
||||||
|
* 手机视图上的绘制位置。抽成纯 Kotlin 是为了能被 JVM 单测钉死(V-D1 判据依赖它)。
|
||||||
|
*
|
||||||
|
* 为什么映射就是简单的等比缩放:[RemoteSurfaceView] 是 letterbox——视图宽高比恒等于
|
||||||
|
* 远端桌面宽高比(`videoAspectRatio = remoteW / remoteH`),且视图左上角对应桌面 (0,0)。
|
||||||
|
* 于是「视图内像素 ↔ 桌面像素」是一个线性等比,与 [RelPointer.toDesktopDelta] 用的是
|
||||||
|
* **同一套比例**(那里是反向:视图像素增量→桌面像素增量)。
|
||||||
|
*
|
||||||
|
* 这里的边界校验**不是**对被控端 `fold_cursor` 的重复防御:那条在 Windows 被控端,
|
||||||
|
* 而本协议把对端字节一律按**不可信输入**处理(见 viewer-core 纪律、[RustBridge.nextFrame] 的长度闸)。
|
||||||
|
* 跨信任边界的输入必须自证——尤其 Linux 后端当前恒发 None,未来实装时若折叠写错,
|
||||||
|
* 这里不兜住就会把标记画到画面外。
|
||||||
|
*/
|
||||||
|
object CursorMarker {
|
||||||
|
|
||||||
|
/** 映射后的绘制点(视图内浮点像素)。绘制侧([CursorOverlayView])按此画圆环中心。 */
|
||||||
|
data class OnView(val x: Float, val y: Float)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param desktopW/desktopH 远端采集画面尺寸(物理像素,来自 AuthOk/desktopSize)
|
||||||
|
* @param cursor 帧头光标坐标(桌面物理像素),null = 隐藏/离屏/未实现
|
||||||
|
* @param viewW/viewH SurfaceView 当前测量尺寸(像素)
|
||||||
|
* @return 视图内绘制点;任一维度非法或坐标越界 → null(不画标记)
|
||||||
|
*
|
||||||
|
* null 的所有分支都必须走「不画」而不是「画在 (0,0)」:(0,0) 是一个**合法**的光标位置
|
||||||
|
* (左上角),把它当成默认值会让「没有光标」和「光标在左上角」看起来一样——
|
||||||
|
* 这正是协议用 Option 而不用 0 的原因,Kotlin 侧不能把这层区分抹平。
|
||||||
|
*/
|
||||||
|
fun mapToView(
|
||||||
|
desktopW: Int,
|
||||||
|
desktopH: Int,
|
||||||
|
cursor: Pair<Int, Int>?,
|
||||||
|
viewW: Int,
|
||||||
|
viewH: Int,
|
||||||
|
): OnView? {
|
||||||
|
if (cursor == null) return null
|
||||||
|
// 尺寸未就绪(视图尚未测量 / desktopSize 还没取到):不画,而不是按 0 除
|
||||||
|
if (desktopW <= 0 || desktopH <= 0 || viewW <= 0 || viewH <= 0) return null
|
||||||
|
val (cx, cy) = cursor
|
||||||
|
// 采集画面 [0,w)×[0,h) 之外不画(越界 = 后端折叠漏网或被篡改)
|
||||||
|
if (cx < 0 || cy < 0 || cx >= desktopW || cy >= desktopH) return null
|
||||||
|
return OnView(
|
||||||
|
x = cx.toFloat() * viewW.toFloat() / desktopW.toFloat(),
|
||||||
|
y = cy.toFloat() * viewH.toFloat() / desktopH.toFloat(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.view.View
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远端光标标记层(4b/Q1=A、Q2=A、Q6=A):画在 [RemoteSurfaceView] **之上**的透明兄弟视图。
|
||||||
|
*
|
||||||
|
* 为什么是兄弟视图而不是 SurfaceView 自己画:SurfaceView 的内容合成在窗口之下
|
||||||
|
* (窗口在其位置打透明洞),同一视图内无法给自己的 surface 叠加绘制。兄弟 View 盖在
|
||||||
|
* 洞上方是标准做法(视频播放器控件同理)。[RemoteSurfaceView] 保持纯采集/触摸职责不变。
|
||||||
|
*
|
||||||
|
* 坐标换算全部委托 [CursorMarker.mapToView](纯逻辑、JVM 单测钉死);本类只负责:
|
||||||
|
* 接收解码线程写来的光标 → invalidate → 在 UI 线程把「桌面坐标」画成「屏幕坐标」。
|
||||||
|
* 锚点几何(surfaceView 的 x/y/width/height)在 onDraw 现取——旋转/重布局后自动跟新,
|
||||||
|
* 无需订阅布局回调。
|
||||||
|
*
|
||||||
|
* 线程纪律:[updateCursor] 由**解码线程**每帧调用(与 stopDecoding 同款的跨线程写入),
|
||||||
|
* 字段 volatile + [postInvalidate](安卓文档指定的跨线程失效 API)。绘制侧只在 UI 线程读。
|
||||||
|
*
|
||||||
|
* 触摸:本视图**不消费任何触摸**(不可点击、不可聚焦)——它覆盖整个 stage,若吃掉事件
|
||||||
|
* 会同时废掉远端操作和黑边唤出(R-7/4a 的判据会当场全红)。事件按 FrameLayout 规则
|
||||||
|
* 下沉到 SurfaceView,行为与 4a 完全一致。
|
||||||
|
*/
|
||||||
|
class CursorOverlayView(context: Context) : View(context) {
|
||||||
|
|
||||||
|
/** 标记直径 22dp(Q2=A)。横屏 480dpi 下 ≈66px ≈ 桌面 33px 等比,够看清又不挡目标。 */
|
||||||
|
private val markerRadiusPx = 11f * context.resources.displayMetrics.density
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中心点兼作 proof 颜色特征码(青色非纯白):真机判据 V-D1 用 screencap 找这个
|
||||||
|
* 颜色的像素簇并断言簇心 == 期望映射点。覆盖层绘制发生在解码**之后**的 UI 层,
|
||||||
|
* 不经过 H.264 有损链路,screencap 里颜色精确可匹配。圆环仍按 Q2 双描边。
|
||||||
|
*/
|
||||||
|
private val dotColor = Color.rgb(0x00, 0xE5, 0xFF)
|
||||||
|
|
||||||
|
private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
color = Color.WHITE
|
||||||
|
strokeWidth = 2f * context.resources.displayMetrics.density
|
||||||
|
}
|
||||||
|
private val outerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
color = Color.BLACK
|
||||||
|
strokeWidth = 4f * context.resources.displayMetrics.density
|
||||||
|
}
|
||||||
|
private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
color = dotColor
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前帧头光标(桌面物理像素)。null = 无标记:还没收到带光标的帧、
|
||||||
|
* 指针隐藏/离屏、或被控端后端未实现——三种情况都必须**什么都不画**。
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var cursor: Pair<Int, Int>? = null
|
||||||
|
|
||||||
|
/** 解码线程调用;null 也走这里(清除标记同样必须触发重绘,否则标记会留在原位)。 */
|
||||||
|
fun updateCursor(newCursor: Pair<Int, Int>?) {
|
||||||
|
cursor = newCursor
|
||||||
|
postInvalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由 Activity 接线:几何锚点就是 SurfaceView(letterbox 后的实际画面矩形)。 */
|
||||||
|
var anchor: RemoteSurfaceView? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
isClickable = false
|
||||||
|
isFocusable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
val a = anchor ?: return
|
||||||
|
val c = cursor ?: return
|
||||||
|
val p = CursorMarker.mapToView(
|
||||||
|
desktopW = a.remoteWidth,
|
||||||
|
desktopH = a.remoteHeight,
|
||||||
|
cursor = c,
|
||||||
|
viewW = a.width,
|
||||||
|
viewH = a.height,
|
||||||
|
) ?: return
|
||||||
|
// 视图内坐标 + 画面在 stage 里的原点偏移(黑边存在时不为 0)
|
||||||
|
val sx = a.x + p.x
|
||||||
|
val sy = a.y + p.y
|
||||||
|
// 双描边:先粗黑再细白——白环两侧各露 1dp 黑边,深浅背景都保底可见(Q2)
|
||||||
|
canvas.drawCircle(sx, sy, markerRadiusPx, outerRingPaint)
|
||||||
|
canvas.drawCircle(sx, sy, markerRadiusPx, ringPaint)
|
||||||
|
canvas.drawCircle(sx, sy, markerRadiusPx * 0.24f, dotPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.Surface
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MediaCodec H.264 解码器封装(阶段 1:同步模式 + Surface 直出)。
|
||||||
|
*
|
||||||
|
* 两条实测教训直接写进实现(来源见个人层 lessons / baseline):
|
||||||
|
* 1. Exynos 等设备带 requires-allocate-on-input/output-ports Quirk,
|
||||||
|
* 必须用 getInputBuffer(i)/getOutputBuffer(i),不用已废弃的数组版 API。
|
||||||
|
* 2. 参考帧链断裂后 P 帧必然解不出:解码线程维护 awaitingIdr 状态,
|
||||||
|
* 只喂 IDR 之后的帧;错误后 flush + requestKeyframe 并等 IDR(R-3)。
|
||||||
|
*
|
||||||
|
* MediaFormat 不设 csd:码流每个 IDR 自带 SPS/PPS(dump_stream 实测
|
||||||
|
* "SPS PPS IDR" 结构),解码器从码流内联参数集自举。
|
||||||
|
*/
|
||||||
|
class H264Decoder(private val surface: Surface) {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var stopped = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var codec: MediaCodec? = null
|
||||||
|
private val bufferInfo = MediaCodec.BufferInfo()
|
||||||
|
|
||||||
|
/** 等待 IDR 标志:true 时 P 帧一律不喂解码器 */
|
||||||
|
private var awaitingIdr = true
|
||||||
|
|
||||||
|
private var configuredW = 0
|
||||||
|
private var configuredH = 0
|
||||||
|
|
||||||
|
/** 解码一帧。返回 false = 该帧被丢弃(等 IDR 中或缓冲满),不视为错误。 */
|
||||||
|
fun queue(frame: RustBridge.Frame): Boolean {
|
||||||
|
// 等待 IDR:参考链未建立/已断裂时,P 帧喂进去只会产生错误
|
||||||
|
if (awaitingIdr && !frame.keyframe) return false
|
||||||
|
if (frame.keyframe) awaitingIdr = false
|
||||||
|
|
||||||
|
val c = ensureCodec(frame) ?: return false
|
||||||
|
return try {
|
||||||
|
val idx = c.dequeueInputBuffer(INPUT_TIMEOUT_US)
|
||||||
|
if (idx < 0) return false // 输入满:阶段 1 直接丢,由消费方决定
|
||||||
|
val inBuf: ByteBuffer = c.getInputBuffer(idx) ?: return false
|
||||||
|
inBuf.clear()
|
||||||
|
inBuf.put(frame.payload)
|
||||||
|
c.queueInputBuffer(idx, 0, frame.payload.size, frame.tsMs * 1000, 0)
|
||||||
|
drainOnce(c)
|
||||||
|
true
|
||||||
|
} catch (e: MediaCodec.CodecException) {
|
||||||
|
Log.e(TAG, "codec error isRecoverable=${e.isRecoverable}", e)
|
||||||
|
invalidate()
|
||||||
|
false
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
Log.e(TAG, "codec illegal state", e)
|
||||||
|
invalidate()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解码错误/跳帧后调用:flush 复位解码器(三级恢复第一级),
|
||||||
|
* 置等待标志;调用方负责 requestKeyframe。 */
|
||||||
|
fun invalidate() {
|
||||||
|
awaitingIdr = true
|
||||||
|
try {
|
||||||
|
codec?.flush()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// flush 失败说明解码器不可恢复:销毁,等下一个 IDR 重建
|
||||||
|
Log.w(TAG, "flush failed, releasing codec", e)
|
||||||
|
releaseCodec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确保解码器就绪。只在 keyframe 到来时才配置/重建(首配与重配同路径)。 */
|
||||||
|
private fun ensureCodec(frame: RustBridge.Frame): MediaCodec? {
|
||||||
|
val c = codec
|
||||||
|
if (c != null && configuredW == frame.width && configuredH == frame.height) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
if (c != null) {
|
||||||
|
Log.i(TAG, "size changed ${configuredW}x$configuredH -> ${frame.width}x${frame.height}, reconfigure")
|
||||||
|
releaseCodec()
|
||||||
|
}
|
||||||
|
if (codec == null && frame.keyframe) {
|
||||||
|
val name = pickDecoderName()
|
||||||
|
val nc = if (name.isEmpty()) MediaCodec.createDecoderByType(MIME)
|
||||||
|
else MediaCodec.createByCodecName(name)
|
||||||
|
val fmt = MediaFormat.createVideoFormat(MIME, frame.width, frame.height)
|
||||||
|
nc.configure(fmt, surface, null, 0)
|
||||||
|
nc.start()
|
||||||
|
codec = nc
|
||||||
|
configuredW = frame.width
|
||||||
|
configuredH = frame.height
|
||||||
|
Log.i(TAG, "decoder configured ${frame.width}x${frame.height} name=${nc.name}")
|
||||||
|
}
|
||||||
|
return codec
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 优先硬解;查不到就返回空串让系统自选(避免选中软件解码器)。 */
|
||||||
|
private fun pickDecoderName(): String {
|
||||||
|
return try {
|
||||||
|
android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS)
|
||||||
|
.codecInfos
|
||||||
|
.firstOrNull { info ->
|
||||||
|
!info.isEncoder &&
|
||||||
|
info.supportedTypes.any { t -> t.equals(MIME, true) } &&
|
||||||
|
!info.name.startsWith("OMX.google.") &&
|
||||||
|
!info.name.startsWith("c2.android.")
|
||||||
|
}?.name ?: ""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drainOnce(c: MediaCodec) {
|
||||||
|
while (!stopped) {
|
||||||
|
val idx = c.dequeueOutputBuffer(bufferInfo, 0)
|
||||||
|
when {
|
||||||
|
idx == MediaCodec.INFO_TRY_AGAIN_LATER -> return
|
||||||
|
idx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED ->
|
||||||
|
Log.i(TAG, "output format: ${c.outputFormat}")
|
||||||
|
idx >= 0 -> {
|
||||||
|
// Surface 直出:渲染由解码器完成,不取图像数据
|
||||||
|
c.releaseOutputBuffer(idx, true)
|
||||||
|
}
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
stopped = true
|
||||||
|
releaseCodec()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseCodec() {
|
||||||
|
try {
|
||||||
|
codec?.stop()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "stop ignored", e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
codec?.release()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "release ignored", e)
|
||||||
|
}
|
||||||
|
codec = null
|
||||||
|
configuredW = 0
|
||||||
|
configuredH = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "H264Decoder"
|
||||||
|
private const val MIME = "video/avc"
|
||||||
|
private const val INPUT_TIMEOUT_US = 4_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,60 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
/** 4a:指针方向策略(Q1=A:默认 AUTO)。 */
|
||||||
|
enum class OrientationPolicy { AUTO, LANDSCAPE, PORTRAIT, SYSTEM }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方向策略 → `Activity.requestedOrientation` 的纯映射(4a)。
|
||||||
|
*
|
||||||
|
* 抽成纯 Kotlin 的原因(与 WinScancode/ConnectErrorText 同一模式):
|
||||||
|
* 映射表必须能在 JVM 单测里钉死,而 `ActivityInfo` 常量在 android.jar 桩上
|
||||||
|
* 引用类加载有坑,这里直接落整数常量。
|
||||||
|
*
|
||||||
|
* 数值来源(不凭记忆,2026-11-01 `javap -constants` 对 android-34 android.jar 核实):
|
||||||
|
* - SCREEN_ORIENTATION_LANDSCAPE = 0
|
||||||
|
* - SCREEN_ORIENTATION_PORTRAIT = 1
|
||||||
|
* - SCREEN_ORIENTATION_UNSPECIFIED = -1
|
||||||
|
* 刻意只用这三个:探针在真机验证过 LANDSCAPE(0) 与 PORTRAIT 行为;
|
||||||
|
* SENSOR_LANDSCAPE(6)(允许左右横)未在本机验证过,不引入——固定横屏对
|
||||||
|
* 「看 16:9 桌面」的场景足够,翻转手机方向时系统不会再把画面翻到另一侧,
|
||||||
|
* 这反而是更稳定的行为。
|
||||||
|
*/
|
||||||
|
object Orientation {
|
||||||
|
|
||||||
|
const val REQUEST_LANDSCAPE = 0
|
||||||
|
const val REQUEST_PORTRAIT = 1
|
||||||
|
const val REQUEST_UNSPECIFIED = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AUTO 的语义:远端画面是横的(宽>高)就用横屏,否则竖屏。
|
||||||
|
* 尺寸未知(0,连接前/断开后)按竖屏兜底——表单页本来就是竖屏。
|
||||||
|
*/
|
||||||
|
fun requestFor(policy: OrientationPolicy, remoteW: Int, remoteH: Int): Int = when (policy) {
|
||||||
|
OrientationPolicy.AUTO ->
|
||||||
|
if (remoteW > remoteH) REQUEST_LANDSCAPE else REQUEST_PORTRAIT
|
||||||
|
OrientationPolicy.LANDSCAPE -> REQUEST_LANDSCAPE
|
||||||
|
OrientationPolicy.PORTRAIT -> REQUEST_PORTRAIT
|
||||||
|
OrientationPolicy.SYSTEM -> REQUEST_UNSPECIFIED
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 方向按钮的循环次序(Q4=A):AUTO→横→竖→系统→AUTO。 */
|
||||||
|
fun next(policy: OrientationPolicy): OrientationPolicy = when (policy) {
|
||||||
|
OrientationPolicy.AUTO -> OrientationPolicy.LANDSCAPE
|
||||||
|
OrientationPolicy.LANDSCAPE -> OrientationPolicy.PORTRAIT
|
||||||
|
OrientationPolicy.PORTRAIT -> OrientationPolicy.SYSTEM
|
||||||
|
OrientationPolicy.SYSTEM -> OrientationPolicy.AUTO
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按钮文案。每个策略都必须有非空、互不相同的文案(单测锁)。 */
|
||||||
|
fun label(policy: OrientationPolicy): String = when (policy) {
|
||||||
|
OrientationPolicy.AUTO -> "方向:自动"
|
||||||
|
OrientationPolicy.LANDSCAPE -> "方向:横屏"
|
||||||
|
OrientationPolicy.PORTRAIT -> "方向:竖屏"
|
||||||
|
OrientationPolicy.SYSTEM -> "方向:系统"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** prefs 读写:未知字符串(版本回退/手改)必须落回 AUTO,不得抛。 */
|
||||||
|
fun fromName(name: String?): OrientationPolicy =
|
||||||
|
runCatching { OrientationPolicy.valueOf(name.orEmpty().uppercase()) }
|
||||||
|
.getOrDefault(OrientationPolicy.AUTO)
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相对鼠标的**纯逻辑**:把「手指在视图上移动了多少像素」换算成「远端指针该走多少桌面像素」,
|
||||||
|
* 并做小数累积取整。抽成纯 Kotlin 是为了能在 JVM 单测里验证(V-B1 的判据依赖它)。
|
||||||
|
*
|
||||||
|
* 为什么不直接发 `deltaViewPx`:
|
||||||
|
* - 视图尺寸 ≠ 桌面尺寸(letterbox 后视图是 1080x607,桌面是 1920x1080);
|
||||||
|
* - `sensitivity != 1.0` 时还要缩放。
|
||||||
|
* 两步合并成一次乘法,避免分两次取整(每多一次取整就多一个累积误差源)。
|
||||||
|
*
|
||||||
|
* 为什么不直接 `toInt()` 就发:
|
||||||
|
* 手指慢速滑动时每次 `ACTION_MOVE` 的增量可能只有 0.3 个桌面像素,`toInt()` 逐次截断会让
|
||||||
|
* **位移被静默吞掉**(V-B1「慢速小幅滑动不丢位移」直接不成立)。必须把余数累积到下一次。
|
||||||
|
*/
|
||||||
|
object RelPointer {
|
||||||
|
|
||||||
|
/** 灵敏度允许范围(Q4=A:默认 1.0,可调 0.25–3.0) */
|
||||||
|
const val MIN_SENSITIVITY = 0.25f
|
||||||
|
const val MAX_SENSITIVITY = 3.0f
|
||||||
|
const val DEFAULT_SENSITIVITY = 1.0f
|
||||||
|
|
||||||
|
fun clampSensitivity(v: Float): Float = v.coerceIn(MIN_SENSITIVITY, MAX_SENSITIVITY)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视图像素增量 → 桌面像素增量(**浮点,未取整**)。
|
||||||
|
*
|
||||||
|
* `sensitivity = 1.0` 的语义:手指扫过视图整个宽度 = 指针走完整个桌面宽度。
|
||||||
|
* 这样相对模式与绝对模式的手感一致(绝对模式下 10% 视图宽 = 10% 桌面宽)。
|
||||||
|
*
|
||||||
|
* 任一维度非法(<=0,例如视图尚未测量、远端尺寸未取到)时返回 0:
|
||||||
|
* 宁可不移动,也不要按错误比例乱飞。
|
||||||
|
*/
|
||||||
|
fun toDesktopDelta(
|
||||||
|
viewDeltaPx: Float,
|
||||||
|
viewExtentPx: Int,
|
||||||
|
remoteExtentPx: Int,
|
||||||
|
sensitivity: Float,
|
||||||
|
): Float {
|
||||||
|
if (viewExtentPx <= 0 || remoteExtentPx <= 0) return 0f
|
||||||
|
if (viewDeltaPx == 0f) return 0f
|
||||||
|
val scale = remoteExtentPx.toFloat() / viewExtentPx.toFloat()
|
||||||
|
return viewDeltaPx * scale * clampSensitivity(sensitivity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小数累积器:把连续的浮点位移攒成整数像素再发送,余数留到下一次。
|
||||||
|
*
|
||||||
|
* 保证:连续输入之和与单独一次输入等价(不丢位移);每次发出的都是整数(协议是 i16)。
|
||||||
|
* 撞边等外部原因丢弃的位移**不回补**——累积器只负责「取整」,不负责「补偿」,
|
||||||
|
* 否则撞边后回滑会出现一段"空转"(V-B1 第三条判据要求撞边后立即响应)。
|
||||||
|
*/
|
||||||
|
class RelAccumulator {
|
||||||
|
private var accX = 0f
|
||||||
|
private var accY = 0f
|
||||||
|
|
||||||
|
/** 会话/手势结束时清空,避免上一段手势的余数漏到下一段 */
|
||||||
|
fun reset() {
|
||||||
|
accX = 0f
|
||||||
|
accY = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 累积一段位移,返回**本次可以发送的整数增量**;不足 1 像素时返回 null(不发)。
|
||||||
|
*
|
||||||
|
* X/Y 独立累积:斜向慢速滑动时两个轴各自攒够才发,不会因为一个轴没到 1 就把另一个轴也拖住。
|
||||||
|
*/
|
||||||
|
fun take(dxDesktop: Float, dyDesktop: Float): Pair<Int, Int>? {
|
||||||
|
accX += dxDesktop
|
||||||
|
accY += dyDesktop
|
||||||
|
// roundToInt 而非 toInt:-0.7 应取 -1 而不是 0(向零截断会让反向慢滑丢位移)
|
||||||
|
val ix = accX.roundToInt()
|
||||||
|
val iy = accY.roundToInt()
|
||||||
|
if (ix == 0 && iy == 0) return null
|
||||||
|
accX -= ix
|
||||||
|
accY -= iy
|
||||||
|
// 协议是 i16;单次事件不可能接近这个范围,但显式 clamp 让越界行为有界
|
||||||
|
return ix.coerceIn(-32768, 32767) to iy.coerceIn(-32768, 32767)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 长按右键的判定(Q5=A:≥500ms 且位移 < slop)。
|
||||||
|
*
|
||||||
|
* 抽成纯逻辑的原因:真实触发依赖 Handler 计时与 MotionEvent,JVM 里不可构造;
|
||||||
|
* 但「什么条件下该弹右键」是纯判断,必须能被单测钉住(尤其"拖窗口不弹菜单"这条)。
|
||||||
|
*/
|
||||||
|
object LongPressPolicy {
|
||||||
|
|
||||||
|
/** 长按触发阈值(毫秒) */
|
||||||
|
const val LONG_PRESS_MS = 500L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否应触发长按右键。
|
||||||
|
*
|
||||||
|
* @param elapsedMs 从 ACTION_DOWN 起经过的时间
|
||||||
|
* @param movedPx 期间的最大位移(欧氏距离)
|
||||||
|
* @param slopPx 拖动阈值(12dp),超过即视为拖拽而非长按
|
||||||
|
* @param twoFinger 是否出现过第二根手指(双指手势有自己的右键路径,不能叠加)
|
||||||
|
*
|
||||||
|
* 三条都必须满足:够久、没怎么动、不是双指。
|
||||||
|
* 「拖窗口不弹菜单」靠 movedPx 这条——超过 slop 时 [RemoteSurfaceView] 会切到 DRAG
|
||||||
|
* 并取消计时任务,所以这里通常压根不会被调用;但作为纯逻辑仍要自洽。
|
||||||
|
*/
|
||||||
|
fun shouldFire(
|
||||||
|
elapsedMs: Long,
|
||||||
|
movedPx: Float,
|
||||||
|
slopPx: Float,
|
||||||
|
twoFinger: Boolean,
|
||||||
|
): Boolean {
|
||||||
|
if (twoFinger) return false
|
||||||
|
if (elapsedMs < LONG_PRESS_MS) return false
|
||||||
|
return abs(movedPx) < slopPx
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,154 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.inputmethod.BaseInputConnection
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
|
||||||
|
/** 单次退格请求上限:防 IME 异常大值刷爆消息队列(与 agent 侧 WHEEL_MAX_CLICKS 同思路) */
|
||||||
|
const val MAX_BACKSPACE = 64
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退格决策(纯函数 [planBackspaces] 的输出)。类型刻意放在类外:
|
||||||
|
* `RemoteInputConnection` 构造链到 `BaseInputConnection`(android.jar 桩,JVM 单测
|
||||||
|
* 里抛 "not mocked"),决策本身无 Android 依赖——抽出来才能被 3a 的 JVM 单测覆盖(U7)。
|
||||||
|
*/
|
||||||
|
sealed class BackspaceAction {
|
||||||
|
/** (0,0):IME 没有要删的东西 */
|
||||||
|
object Noop : BackspaceAction()
|
||||||
|
|
||||||
|
/** 退格 [count] 次(已截断到 [MAX_BACKSPACE]) */
|
||||||
|
data class Backspaces(val count: Int) : BackspaceAction()
|
||||||
|
|
||||||
|
/** `after>0` / 双向删除:本地无远端真值,不可执行(U3) */
|
||||||
|
data class Unsupported(val before: Int, val after: Int) : BackspaceAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `deleteSurroundingText(before, after)` 的决策逻辑,从 [RemoteInputConnection] 抽出。
|
||||||
|
* 语义与原实现一致(3a 只提取不改动):
|
||||||
|
* - `(0,0)` → Noop;
|
||||||
|
* - `after>0` → Unsupported(forward-delete 形态本地无法执行,U3);
|
||||||
|
* - `before>0 && after==0` → 退格 min(before, [MAX_BACKSPACE]) 次
|
||||||
|
* (上限防 IME 异常大值刷爆消息队列,与 agent 侧 WHEEL_MAX_CLICKS 同思路)。
|
||||||
|
*/
|
||||||
|
fun planBackspaces(before: Int, after: Int, maxBackspace: Int = MAX_BACKSPACE): BackspaceAction =
|
||||||
|
when {
|
||||||
|
before == 0 && after == 0 -> BackspaceAction.Noop
|
||||||
|
after != 0 -> BackspaceAction.Unsupported(before, after)
|
||||||
|
else -> BackspaceAction.Backspaces(minOf(before, maxBackspace))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P1 IME 通道(loop-2b-plan §5,用户已裁决):**本地没有编辑缓冲,远端才是权威**。
|
||||||
|
*
|
||||||
|
* 三条约定(与方案一致):
|
||||||
|
* 1. `commitText` = 用户确认的整段文本 → 直接走协议 v3 `Text` 通道(布局无关、支持中文);
|
||||||
|
* 2. `sendKeyEvent` = 硬键盘/控制键 → 既有扫描码通道(Enter/Backspace/方向键…);
|
||||||
|
* 3. `deleteSurroundingText`:本地不知道远端内容,`before>0 && after==0`
|
||||||
|
* 是 IME 退格最常见形态 → 发对应次数的 Backspace;其余形态记录后放弃(U3,
|
||||||
|
* 决策逻辑抽到纯函数 [planBackspaces],3a 起可 JVM 单测)。
|
||||||
|
*
|
||||||
|
* composing(拼音未确认串)不显示到远端(U4):`setComposingText` 吞掉但返回 true,
|
||||||
|
* 让 IME 维持本地组合状态,候选确认后仍会走 `commitText`。
|
||||||
|
*
|
||||||
|
* 日志纪律:debug 通道只报**长度与事件类型**,不报用户输入内容(安全红线)。
|
||||||
|
*/
|
||||||
|
class RemoteInputConnection(
|
||||||
|
target: View,
|
||||||
|
private val sendText: (CharArray) -> Unit,
|
||||||
|
private val sendKey: (WinKey, Boolean) -> Unit,
|
||||||
|
private val debug: (String) -> Unit,
|
||||||
|
) : BaseInputConnection(target, false) {
|
||||||
|
|
||||||
|
override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
|
||||||
|
val units = text.toString().toCharArray()
|
||||||
|
if (units.isEmpty()) return true
|
||||||
|
sendText(units)
|
||||||
|
debug("commit ${units.size}u")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sendKeyEvent(event: KeyEvent): Boolean {
|
||||||
|
if (event.action == KeyEvent.ACTION_MULTIPLE) {
|
||||||
|
val chars: String? = event.characters
|
||||||
|
if (!chars.isNullOrEmpty()) {
|
||||||
|
sendText(chars.toCharArray())
|
||||||
|
debug("multiple ${chars.length}u")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 字符事件优先于扫描码:多数软键盘发的是 keyCode=KEYCODE_UNKNOWN +
|
||||||
|
// unicodeChar(adb `input text` 同款形态)。这一分支必须排在 WinScancode
|
||||||
|
// 查表**之前**——否则 UNKNOWN(0) 查不到表会被当「未映射」吞掉,字就丢了。
|
||||||
|
// 注意 `getUnicodeChar()` 返回的是单个 UTF-16 code unit(不是码点),
|
||||||
|
// 所以这条路只覆盖 BMP;增补平面字符由 `commitText` 成对送达。
|
||||||
|
// 只收**可打印**字符(空格起、排除 0x7F):控制值(Enter/Tab)必须走扫描码,
|
||||||
|
// 远端应用期待的是按键事件而非 WM_CHAR。见 MainActivity.onKeyDown 同款门槛。
|
||||||
|
val cp = event.unicodeChar
|
||||||
|
if (event.action == KeyEvent.ACTION_DOWN && cp >= 0x20 && cp != 0x7F) {
|
||||||
|
sendText(charArrayOf(cp.toChar()))
|
||||||
|
debug("keychar 1u")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val key = WinScancode.of(event.keyCode)
|
||||||
|
if (key == null) {
|
||||||
|
debug("IME 未映射键 code=${event.keyCode}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sendKey(key, event.action == KeyEvent.ACTION_DOWN)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean =
|
||||||
|
applyBackspaces(planBackspaces(beforeLength, afterLength))
|
||||||
|
|
||||||
|
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean =
|
||||||
|
applyBackspaces(planBackspaces(beforeLength, afterLength))
|
||||||
|
|
||||||
|
/** 决策 → 注入。日志纪律:只报数量与形态,不报内容。 */
|
||||||
|
private fun applyBackspaces(action: BackspaceAction): Boolean = when (action) {
|
||||||
|
is BackspaceAction.Noop -> true
|
||||||
|
is BackspaceAction.Unsupported -> {
|
||||||
|
// U3:forward-delete 与「先删 after 再删 before」的形态本地无远端真值可依
|
||||||
|
debug("delete(${action.before},${action.after}):after>0 形态未支持")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
is BackspaceAction.Backspaces -> {
|
||||||
|
val bk = WinScancode.of(KeyEvent.KEYCODE_DEL) ?: return false
|
||||||
|
repeat(action.count) {
|
||||||
|
sendKey(bk, true)
|
||||||
|
sendKey(bk, false)
|
||||||
|
}
|
||||||
|
debug("backspace x${action.count}")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 吞掉 composing 但返回 true:IME 保持本地组合流程,候选确认仍会 commitText。 */
|
||||||
|
override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean = true
|
||||||
|
|
||||||
|
override fun setComposingRegion(start: Int, end: Int): Boolean = true
|
||||||
|
|
||||||
|
override fun finishComposingText(): Boolean = true
|
||||||
|
|
||||||
|
// 本地无可信缓冲区(远端才是权威),一律回空串——比 null 温和,多数 IME 兼容。
|
||||||
|
override fun getTextBeforeCursor(n: Int, flags: Int): CharSequence = ""
|
||||||
|
override fun getTextAfterCursor(n: Int, flags: Int): CharSequence = ""
|
||||||
|
override fun getSelectedText(flags: Int): CharSequence = ""
|
||||||
|
override fun getCursorCapsMode(reqModes: Int): Int = 0
|
||||||
|
|
||||||
|
override fun performEditorAction(actionCode: Int): Boolean {
|
||||||
|
if (actionCode == EditorInfo.IME_ACTION_DONE) {
|
||||||
|
// 软键盘「完成/回车」在远控语义里就是 Enter
|
||||||
|
WinScancode.of(KeyEvent.KEYCODE_ENTER)?.let {
|
||||||
|
sendKey(it, true)
|
||||||
|
sendKey(it, false)
|
||||||
|
}
|
||||||
|
debug("IME_ACTION_DONE→Enter")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
debug("editorAction $actionCode 忽略")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,428 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.text.InputType
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.view.inputmethod.InputConnection
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.hypot
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保持视频宽高比的 SurfaceView(letterbox),把触摸手势映射为远端鼠标事件,
|
||||||
|
* 并作为软键盘的宿主(协议 v3 文本通道 + 扫描码控制键)。
|
||||||
|
*
|
||||||
|
* 为什么视图尺寸必须等于画面尺寸:触摸坐标按视图宽高归一化到远端 0..=65535。
|
||||||
|
* 若视图被拉伸(阶段 1 用 MATCH_PARENT),画面与视图不再一一对应,手指位置与
|
||||||
|
* 远端指针位置就会错位;letterbox 后黑边不属于本视图,触摸天然只在画面内产生事件。
|
||||||
|
*
|
||||||
|
* 手势映射(桌面远控的通行方案,参考 Moonlight / Scrcpy 的交互形态,
|
||||||
|
* 实现为本项目独立编写):
|
||||||
|
* - 单指轻点 = 左键单击
|
||||||
|
* - 单指按下并移动 = 左键拖拽(窗口拖动、文字选中)
|
||||||
|
* - 单指长按不动 = 右键(协议 v4 起;双指轻点仍可用)
|
||||||
|
* - 双指轻点 = 右键
|
||||||
|
* - 双指上下滑动 = 滚轮
|
||||||
|
*
|
||||||
|
* **两种指针模式(协议 v4)**:
|
||||||
|
* - 相对模式(默认):手指滑动 = 远端指针按**增量**移动,手指只扫一小段就能走很远,
|
||||||
|
* 且不遮挡目标。DOWN 不发送绝对移动——轻点落在**远端指针当前位置**。
|
||||||
|
* - 绝对模式:手指位置直接映射到远端坐标(2a/3a 行为,DOWN 先发绝对 `MouseMove`)。
|
||||||
|
* 两种模式由 [relativeMode] 切换;R7 绝对触摸回归判据依赖绝对模式仍可用。
|
||||||
|
*
|
||||||
|
* **IME 接线(阶段 2b)**:本视图是文本编辑器(`onCheckIsTextEditor`),
|
||||||
|
* 软键盘的提交经 `RemoteInputConnection` 上行。`isFocusableInTouchMode` 必须为
|
||||||
|
* true,否则触摸态拿不到焦点、系统不建立 InputConnection(阶段 2a 只有
|
||||||
|
* `isFocusable`,这就是当时软键盘弹不出来的原因之一)。
|
||||||
|
*/
|
||||||
|
class RemoteSurfaceView(context: Context) : SurfaceView(context) {
|
||||||
|
|
||||||
|
/** 由 Activity 实现,转发到 RustBridge */
|
||||||
|
interface Listener {
|
||||||
|
fun sendMouseMove(x: Int, y: Int)
|
||||||
|
/** 协议 v4:相对位移(被控端桌面物理像素) */
|
||||||
|
fun sendMouseRel(dx: Int, dy: Int)
|
||||||
|
fun sendMouseButton(button: Int, down: Boolean)
|
||||||
|
fun sendWheel(dy: Int)
|
||||||
|
/** 协议 v3:一段 UTF-16 code unit(IME 提交结果) */
|
||||||
|
fun sendText(units: CharArray)
|
||||||
|
/** 控制键(Enter/Backspace/方向键等)走扫描码通道 */
|
||||||
|
fun sendKey(key: WinKey, down: Boolean)
|
||||||
|
/** 连接态:只有已连接才声明自己是文本编辑器,避免未连接时误弹键盘 */
|
||||||
|
fun isReadyForInput(): Boolean
|
||||||
|
/**
|
||||||
|
* 右键注入发生了(3b)。用于把「长按是否真的发了右键」变成**可持久观测**的状态。
|
||||||
|
*
|
||||||
|
* 为什么需要它:`onDebug` 是覆盖式的一行文本,长按后紧跟着的 ACTION_UP 会
|
||||||
|
* 立刻把它改写成「长按结束」——真机验收脚本因此看不到「长按右键」,
|
||||||
|
* 判据被后续事件冲掉。计数器不会丢。
|
||||||
|
*/
|
||||||
|
fun onRightClickInjected(source: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
var listener: Listener? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相对指针模式(协议 v4)。默认 true:桌面远控的通行形态,手指不遮目标。
|
||||||
|
* 切换时清空累积器——上一模式的余数不该漏进新模式。
|
||||||
|
*/
|
||||||
|
var relativeMode: Boolean = true
|
||||||
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field = value
|
||||||
|
relAcc.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 灵敏度(仅相对模式生效)。默认 1.0 = 手指扫过视图全宽,指针走完桌面全宽。 */
|
||||||
|
var sensitivity: Float = RelPointer.DEFAULT_SENSITIVITY
|
||||||
|
set(value) {
|
||||||
|
field = RelPointer.clampSensitivity(value)
|
||||||
|
relAcc.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 远端桌面像素尺寸(相对模式换算用);未连接时为 0,此时相对移动不发送 */
|
||||||
|
var remoteWidth: Int = 0
|
||||||
|
var remoteHeight: Int = 0
|
||||||
|
|
||||||
|
/** 视频宽高比(w/h)。>0 时视图按此比例测量。 */
|
||||||
|
var videoAspectRatio = 0f
|
||||||
|
set(value) {
|
||||||
|
if (value > 0f && field != value) {
|
||||||
|
field = value
|
||||||
|
requestLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class Mode { IDLE, TAP, DRAG, TWO_FINGER }
|
||||||
|
|
||||||
|
private var mode = Mode.IDLE
|
||||||
|
private var downX = 0f
|
||||||
|
private var downY = 0f
|
||||||
|
private var lastX = 0f
|
||||||
|
private var lastY = 0f
|
||||||
|
private var downTime = 0L
|
||||||
|
private var twoFingerStartY = 0f
|
||||||
|
private var twoFingerDownTime = 0L
|
||||||
|
private var twoFingerMoved = false
|
||||||
|
private var wheelAccum = 0f
|
||||||
|
|
||||||
|
/** 相对模式的小数累积器(慢速滑动不丢位移) */
|
||||||
|
private val relAcc = RelAccumulator()
|
||||||
|
|
||||||
|
/** 长按右键:计时任务 + 是否已消费本次手势的 UP(防「先右键再左键」) */
|
||||||
|
private val ui = Handler(Looper.getMainLooper())
|
||||||
|
private var longPressFired = false
|
||||||
|
private var longPressTwoFinger = false
|
||||||
|
|
||||||
|
/** 判定「拖拽 vs 点击」的位移阈值:12dp */
|
||||||
|
private val dragSlopPx = resources.displayMetrics.density * 12f
|
||||||
|
|
||||||
|
init {
|
||||||
|
isFocusable = true
|
||||||
|
// IME 需要触摸态焦点;没有这行,点「键盘」按钮系统不会建立输入连接
|
||||||
|
isFocusableInTouchMode = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 长按计时任务(Q5=A:≥500ms 且位移 < slop)。
|
||||||
|
* 命名刻意避开 `cancelLongPress`——`View` 已有同名方法,会被要求 override。
|
||||||
|
*/
|
||||||
|
private val longPressTask = Runnable {
|
||||||
|
val l = listener ?: return@Runnable
|
||||||
|
if (mode != Mode.TAP) return@Runnable // 已转拖拽/双指
|
||||||
|
val elapsed = System.currentTimeMillis() - downTime
|
||||||
|
val movedPx = hypot((lastX - downX).toDouble(), (lastY - downY).toDouble()).toFloat()
|
||||||
|
if (!LongPressPolicy.shouldFire(elapsed, movedPx, dragSlopPx, longPressTwoFinger)) {
|
||||||
|
return@Runnable
|
||||||
|
}
|
||||||
|
// 相对模式:在远端指针**当前位置**点右键,不发绝对移动
|
||||||
|
if (!relativeMode) l.sendMouseMove(normX(lastX), normY(lastY))
|
||||||
|
l.sendMouseButton(BTN_RIGHT, true)
|
||||||
|
l.sendMouseButton(BTN_RIGHT, false)
|
||||||
|
longPressFired = true
|
||||||
|
performClick()
|
||||||
|
onDebug?.invoke("长按右键")
|
||||||
|
listener?.onRightClickInjected("长按")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelLongPressTimer() {
|
||||||
|
ui.removeCallbacks(longPressTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IME 通道 ----
|
||||||
|
|
||||||
|
override fun onCheckIsTextEditor(): Boolean = listener?.isReadyForInput() == true
|
||||||
|
|
||||||
|
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
||||||
|
val l = listener ?: return null
|
||||||
|
if (!l.isReadyForInput()) return null
|
||||||
|
// TYPE_CLASS_TEXT:让输入法给出正常键盘(含中文候选)。
|
||||||
|
// 常量从 InputType 引用而非 EditorInfo:Kotlin 不保证经实现类名访问接口常量。
|
||||||
|
// 刻意不加 TYPE_TEXT_FLAG_NO_SUGGESTIONS——中文输入依赖候选词。
|
||||||
|
outAttrs.inputType = InputType.TYPE_CLASS_TEXT
|
||||||
|
// 抑制全屏/extract 模式抢走画面(API 29 上两个标志都还在,具体效果真机验证,见 U4/U5)
|
||||||
|
outAttrs.imeOptions =
|
||||||
|
EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||||
|
// 右下动作键文案。字段名是 actionLabel(javap android-34 核实,不是 imeActionLabel)
|
||||||
|
outAttrs.actionLabel = "发送"
|
||||||
|
return RemoteInputConnection(
|
||||||
|
target = this,
|
||||||
|
sendText = { l.sendText(it) },
|
||||||
|
sendKey = { k, down -> l.sendKey(k, down) },
|
||||||
|
debug = { msg -> onDebug?.invoke(msg) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* letterbox 测量:在父容器给出的可用空间内按视频宽高比取最大内接矩形。
|
||||||
|
*
|
||||||
|
* 关键:父容器给 WRAP_CONTENT 时 MeasureSpec 是 AT_MOST,getSize() 是
|
||||||
|
* **上限**而非实际可用尺寸。据此算出的尺寸必须自己 setMeasuredDimension,
|
||||||
|
* 否则父容器会把视图撑满(2026-10-30 实测:视图变成 1080x2001 而非
|
||||||
|
* 1080x607,触摸按 2001 归一化 → 坐标全错,点中心落到画面外)。
|
||||||
|
*/
|
||||||
|
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
|
||||||
|
val ar = videoAspectRatio
|
||||||
|
if (ar <= 0f) {
|
||||||
|
super.onMeasure(widthSpec, heightSpec)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val availW = MeasureSpec.getSize(widthSpec)
|
||||||
|
val availH = MeasureSpec.getSize(heightSpec)
|
||||||
|
if (availW <= 0 || availH <= 0) {
|
||||||
|
super.onMeasure(widthSpec, heightSpec)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var w = availW
|
||||||
|
var h = (availW / ar).toInt()
|
||||||
|
if (h > availH) {
|
||||||
|
h = availH
|
||||||
|
w = (availH * ar).toInt()
|
||||||
|
}
|
||||||
|
setMeasuredDimension(w.coerceAtLeast(1), h.coerceAtLeast(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normX(x: Float) = (x / width * 65535).toInt().coerceIn(0, 65535)
|
||||||
|
private fun normY(y: Float) = (y / height * 65535).toInt().coerceIn(0, 65535)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相对模式下把视图像素增量发出去(经累积取整)。
|
||||||
|
*
|
||||||
|
* 为什么 TAP/DRAG 都走这一个函数:两个分支都必须用**同一个**累积器,
|
||||||
|
* 分成两套会让 TAP→DRAG 切换时丢一次余数。
|
||||||
|
*/
|
||||||
|
private fun sendRelative(l: Listener, fromX: Float, fromY: Float, toX: Float, toY: Float) {
|
||||||
|
val dx = RelPointer.toDesktopDelta(toX - fromX, width, remoteWidth, sensitivity)
|
||||||
|
val dy = RelPointer.toDesktopDelta(toY - fromY, height, remoteHeight, sensitivity)
|
||||||
|
if (dx == 0f && dy == 0f) return
|
||||||
|
val d = relAcc.take(dx, dy) ?: return
|
||||||
|
l.sendMouseRel(d.first, d.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绝对模式下按当前手指位置发归一化坐标 */
|
||||||
|
private fun sendAbsolute(l: Listener, x: Float, y: Float) {
|
||||||
|
l.sendMouseMove(normX(x), normY(y))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moved(fromX: Float, fromY: Float, toX: Float, toY: Float) =
|
||||||
|
hypot((toX - fromX).toDouble(), (toY - fromY).toDouble()) > dragSlopPx
|
||||||
|
|
||||||
|
private fun midX(e: MotionEvent) =
|
||||||
|
if (e.pointerCount >= 2) (e.getX(0) + e.getX(1)) / 2f else e.x
|
||||||
|
|
||||||
|
private fun midY(e: MotionEvent) =
|
||||||
|
if (e.pointerCount >= 2) (e.getY(0) + e.getY(1)) / 2f else e.y
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断回调:把触摸情况显示到状态栏(本机 logcat 被系统限流,日志不可靠)。
|
||||||
|
*
|
||||||
|
* 3a 降噪(V-A5):只显示**事件语义**(单击/拖拽/右键/滚轮),不再显示
|
||||||
|
* 坐标与归一化值——那 20 列噪声挤满状态栏,且对操作者没有决策价值
|
||||||
|
* (坐标类验证由 proof 脚本在 PC 侧读光标闭环,不经屏幕)。
|
||||||
|
*/
|
||||||
|
var onDebug: ((String) -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4a/Q3=A:任何触摸都回调一次(Activity 用它重置自动隐藏计时)。
|
||||||
|
*
|
||||||
|
* 唤出区刻意**不在本视图内**(原底部 40dp 边缘带的设计已废弃):那条带会吃掉
|
||||||
|
* 远端桌面底部约 11% 的高度——Windows 任务栏就在那里,点任务栏会变成唤出菜单
|
||||||
|
* 而不是点击任务栏(2026-11-01 R7 回归实测暴露:frac=0.9 的 tap 被边缘带吞掉)。
|
||||||
|
* 改为由 Activity 在 stage 上消费「画面黑边」的触摸:黑边不属于本视图,
|
||||||
|
* 天然与远端操作零冲突。
|
||||||
|
*/
|
||||||
|
var onAnyTouch: (() -> Unit)? = null
|
||||||
|
|
||||||
|
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
onAnyTouch?.invoke()
|
||||||
|
return super.dispatchTouchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
val l = listener ?: run {
|
||||||
|
onDebug?.invoke("触摸(未接线)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (width == 0 || height == 0) return false
|
||||||
|
|
||||||
|
when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
downX = event.x; downY = event.y
|
||||||
|
lastX = event.x; lastY = event.y
|
||||||
|
downTime = System.currentTimeMillis()
|
||||||
|
mode = Mode.TAP
|
||||||
|
longPressFired = false
|
||||||
|
longPressTwoFinger = false
|
||||||
|
relAcc.reset()
|
||||||
|
// 相对模式:**不发**绝对移动。手指落下不该让远端指针跳到自己脚下
|
||||||
|
// (那正是相对鼠标要解决的问题);轻点落在指针当前位置。
|
||||||
|
if (!relativeMode) sendAbsolute(l, event.x, event.y)
|
||||||
|
// 长按右键计时(Q5=A:500ms 且不动)
|
||||||
|
cancelLongPressTimer()
|
||||||
|
ui.postDelayed(longPressTask, LongPressPolicy.LONG_PRESS_MS)
|
||||||
|
onDebug?.invoke("按下")
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_POINTER_DOWN -> {
|
||||||
|
// 第二指落下 = 双指手势开始。若已进入拖拽(远端鼠标按着),
|
||||||
|
// 必须先抬起,否则远端会留下一个被按住未释放的左键。
|
||||||
|
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
|
||||||
|
mode = Mode.TWO_FINGER
|
||||||
|
longPressTwoFinger = true
|
||||||
|
cancelLongPressTimer()
|
||||||
|
twoFingerMoved = false
|
||||||
|
wheelAccum = 0f
|
||||||
|
twoFingerStartY = midY(event)
|
||||||
|
lastX = midX(event); lastY = midY(event)
|
||||||
|
twoFingerDownTime = System.currentTimeMillis()
|
||||||
|
onDebug?.invoke("双指按下")
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_MOVE -> when (mode) {
|
||||||
|
Mode.TAP -> if (moved(downX, downY, event.x, event.y)) {
|
||||||
|
// 超过 slop = 拖拽,长按不再可能(先取消计时,避免拖到一半弹菜单)
|
||||||
|
cancelLongPressTimer()
|
||||||
|
mode = Mode.DRAG
|
||||||
|
l.sendMouseButton(BTN_LEFT, true)
|
||||||
|
if (relativeMode) {
|
||||||
|
// 相对拖拽:从落点开始按增量移动(不先跳到手指位置)
|
||||||
|
relAcc.reset()
|
||||||
|
sendRelative(l, downX, downY, event.x, event.y)
|
||||||
|
} else {
|
||||||
|
sendAbsolute(l, event.x, event.y)
|
||||||
|
}
|
||||||
|
lastX = event.x; lastY = event.y
|
||||||
|
onDebug?.invoke("拖拽中")
|
||||||
|
}
|
||||||
|
|
||||||
|
Mode.DRAG -> {
|
||||||
|
if (relativeMode) {
|
||||||
|
sendRelative(l, lastX, lastY, event.x, event.y)
|
||||||
|
} else {
|
||||||
|
sendAbsolute(l, event.x, event.y)
|
||||||
|
}
|
||||||
|
lastX = event.x; lastY = event.y
|
||||||
|
}
|
||||||
|
|
||||||
|
Mode.TWO_FINGER -> {
|
||||||
|
val cx = midX(event); val cy = midY(event)
|
||||||
|
if (abs(cy - twoFingerStartY) > dragSlopPx || moved(lastX, lastY, cx, cy)) {
|
||||||
|
twoFingerMoved = true
|
||||||
|
}
|
||||||
|
// 手指下移(cy 增大)= 内容向上翻 = Windows 滚轮向下 → dy 取负
|
||||||
|
wheelAccum += (cy - lastY)
|
||||||
|
val notches = (wheelAccum / WHEEL_PX_PER_NOTCH).toInt()
|
||||||
|
if (notches != 0) {
|
||||||
|
l.sendWheel(-notches * WHEEL_DELTA)
|
||||||
|
wheelAccum -= notches * WHEEL_PX_PER_NOTCH
|
||||||
|
onDebug?.invoke(if (notches > 0) "滚轮↓" else "滚轮↑")
|
||||||
|
}
|
||||||
|
lastX = cx; lastY = cy
|
||||||
|
}
|
||||||
|
|
||||||
|
Mode.IDLE -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_POINTER_UP -> {
|
||||||
|
// 双指之一抬起:先判定右键,再把剩余手指的移动视为已结束手势
|
||||||
|
if (mode == Mode.TWO_FINGER) {
|
||||||
|
if (maybeRightClick(l)) onDebug?.invoke("右键")
|
||||||
|
mode = Mode.IDLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
cancelLongPressTimer()
|
||||||
|
when (mode) {
|
||||||
|
Mode.TAP -> {
|
||||||
|
if (longPressFired) {
|
||||||
|
// 长按已弹右键:本次 UP 不能再发左键单击,
|
||||||
|
// 否则一次长按会「先右键再左键」,右键菜单立刻被左键点掉
|
||||||
|
onDebug?.invoke("长按结束")
|
||||||
|
} else {
|
||||||
|
// 轻点 = 左键单击。绝对模式位置已在 DOWN 时发送;
|
||||||
|
// 相对模式点击**远端指针当前位置**,不发任何移动
|
||||||
|
if (!relativeMode) sendAbsolute(l, lastX, lastY)
|
||||||
|
l.sendMouseButton(BTN_LEFT, true)
|
||||||
|
l.sendMouseButton(BTN_LEFT, false)
|
||||||
|
performClick()
|
||||||
|
onDebug?.invoke("单击")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode.DRAG -> {
|
||||||
|
l.sendMouseButton(BTN_LEFT, false)
|
||||||
|
onDebug?.invoke("拖拽结束")
|
||||||
|
}
|
||||||
|
Mode.TWO_FINGER -> {
|
||||||
|
if (maybeRightClick(l)) onDebug?.invoke("右键")
|
||||||
|
}
|
||||||
|
Mode.IDLE -> {}
|
||||||
|
}
|
||||||
|
mode = Mode.IDLE
|
||||||
|
longPressFired = false
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
|
cancelLongPressTimer()
|
||||||
|
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
|
||||||
|
mode = Mode.IDLE
|
||||||
|
longPressFired = false
|
||||||
|
onDebug?.invoke("手势取消")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 双指未移动且按住时间短 → 右键。返回是否真的注入了右键(供诊断显示)。 */
|
||||||
|
private fun maybeRightClick(l: Listener): Boolean {
|
||||||
|
if (twoFingerMoved) return false
|
||||||
|
if (System.currentTimeMillis() - twoFingerDownTime > TWO_FINGER_TAP_MAX_MS) return false
|
||||||
|
// 双指右键沿用「先定位再点」:双指落点本身就是用户的定位意图,
|
||||||
|
// 与单指相对模式「不动指针」的语义不同(2a 起就是这个行为,回归判据依赖它)
|
||||||
|
sendAbsolute(l, lastX, lastY)
|
||||||
|
l.sendMouseButton(BTN_RIGHT, true)
|
||||||
|
l.sendMouseButton(BTN_RIGHT, false)
|
||||||
|
performClick()
|
||||||
|
listener?.onRightClickInjected("双指")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ClickableViewAccessibility")
|
||||||
|
override fun performClick(): Boolean {
|
||||||
|
super.performClick()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val BTN_LEFT = 0
|
||||||
|
const val BTN_RIGHT = 1
|
||||||
|
const val BTN_MIDDLE = 2
|
||||||
|
private const val WHEEL_DELTA = 120
|
||||||
|
private const val WHEEL_PX_PER_NOTCH = 60f
|
||||||
|
private const val TWO_FINGER_TAP_MAX_MS = 500L
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,168 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接失败:Rust 侧分类([kind],`ConnectErrorText.KIND_*`)+ 原始错误文本([detail])。
|
||||||
|
* 界面第一行按分类给中文可执行提示,第二行显示 detail(排障用,见 3a 错误显示纪律)。
|
||||||
|
*/
|
||||||
|
class ConnectFailure(val kind: Int, val detail: String) : Exception(detail)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* viewer-core(Rust)的 JNI 声明。符号约定:Java_com_verify_remote_RustBridge_*,
|
||||||
|
* 与 android-ffi/src/lib.rs 一一对应。
|
||||||
|
*
|
||||||
|
* 句柄纪律(Rust 侧文件头有完整说明):
|
||||||
|
* - nativeConnect 返回 >0 句柄;0 表示失败,错误消息取 [lastError]
|
||||||
|
* - destroy 后必须置 0;**destroy 前必须先停掉解码线程**(否则 use-after-free)
|
||||||
|
*/
|
||||||
|
object RustBridge {
|
||||||
|
|
||||||
|
init {
|
||||||
|
System.loadLibrary("androidffi")
|
||||||
|
}
|
||||||
|
|
||||||
|
private external fun nativeConnect(host: String, port: Int, code: String): Long
|
||||||
|
external fun nativeLastError(): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次 nativeConnect 失败的业务分类(`ConnectErrorText.KIND_*` 编号)。
|
||||||
|
* FFI 契约(android-ffi/src/lib.rs):**必须在 nativeLastError() 之前调用**——
|
||||||
|
* 消息读走时槽位整体清空,颠倒顺序拿到 kind=0。
|
||||||
|
*/
|
||||||
|
external fun nativeLastErrorKind(): Int
|
||||||
|
/** 返回 null = 会话结束;否则 [FrameCodec.FRAME_HEADER_LEN] 字节头 + Annex-B 载荷 */
|
||||||
|
external fun nativeNextFrame(handle: Long): ByteArray?
|
||||||
|
external fun nativeStats(handle: Long): Long
|
||||||
|
external fun nativeDesktopSize(handle: Long): Long
|
||||||
|
external fun nativeSendMouseMove(handle: Long, x: Int, y: Int)
|
||||||
|
/**
|
||||||
|
* 协议 v4:相对移动(单位 = 被控端桌面物理像素)。
|
||||||
|
* Rust 侧把 Int clamp 到 i16,越界不会回绕;零位移在 viewer-core 被拦掉。
|
||||||
|
*/
|
||||||
|
external fun nativeSendMouseRel(handle: Long, dx: Int, dy: Int)
|
||||||
|
external fun nativeSendMouseButton(handle: Long, button: Int, down: Int)
|
||||||
|
external fun nativeSendWheel(handle: Long, dy: Int)
|
||||||
|
external fun nativeSendKey(handle: Long, scancode: Int, extended: Int, down: Int)
|
||||||
|
/**
|
||||||
|
* 协议 v3 文本上行。参数刻意用 `CharArray`(UTF-16 code unit)而不是 `String`:
|
||||||
|
* jni 侧 `get_string` 走 Modified-UTF-8 往返,IME 提交边界的落单代理项会在
|
||||||
|
* 往返中丢失;`char[]` 经 `GetCharArrayRegion` 按 u16 原样拷贝。
|
||||||
|
*/
|
||||||
|
external fun nativeSendText(handle: Long, units: CharArray)
|
||||||
|
external fun nativeRequestKeyframe(handle: Long)
|
||||||
|
external fun nativeDestroy(handle: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接。失败抛 [ConnectFailure](分类 + Rust 侧原始消息)。
|
||||||
|
* 取序遵守 FFI 契约:先 kind 后 message。
|
||||||
|
*/
|
||||||
|
fun connect(host: String, port: Int, code: String): Long {
|
||||||
|
val h = nativeConnect(host, port, code)
|
||||||
|
if (h == 0L) {
|
||||||
|
val kind = nativeLastErrorKind()
|
||||||
|
val msg = nativeLastError() ?: "connect failed"
|
||||||
|
throw ConnectFailure(kind, msg)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy(handle: Long) {
|
||||||
|
if (handle != 0L) nativeDestroy(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解出的帧(头字段已拆,payload 可直接喂 MediaCodec) */
|
||||||
|
data class Frame(
|
||||||
|
val keyframe: Boolean,
|
||||||
|
val seq: Long,
|
||||||
|
val tsMs: Long,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
/**
|
||||||
|
* v5:本帧发送时刻的远端光标(采集画面内物理像素)。
|
||||||
|
* `null` = 隐藏/离屏/后端未实现(语义单一来源在被控端,见 common 协议注释)。
|
||||||
|
* 用一个可空 Pair 而非两个可空 Int:忠实对应 `Option<(i16,i16)>`,
|
||||||
|
* 杜绝「x 有 y 无」这种协议里不存在的状态。
|
||||||
|
*/
|
||||||
|
val cursor: Pair<Int, Int>?,
|
||||||
|
val payload: ByteArray,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 取帧 + 拆头。null = 会话结束。 */
|
||||||
|
fun nextFrame(handle: Long): Frame? {
|
||||||
|
val raw = nativeNextFrame(handle) ?: return null
|
||||||
|
return FrameCodec.parse(raw)
|
||||||
|
}
|
||||||
|
fun rttMs(handle: Long): Long {
|
||||||
|
val packed = nativeStats(handle)
|
||||||
|
return if (packed < 0) 0 else (packed ushr 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun framesIn(handle: Long): Long {
|
||||||
|
val packed = nativeStats(handle)
|
||||||
|
return if (packed < 0) 0 else packed and 0xFFFF_FFFF
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 被控端桌面尺寸 (w, h)。连接成功后、建解码器之前调用。
|
||||||
|
* 返回 (0,0) 表示句柄无效。
|
||||||
|
*/
|
||||||
|
fun desktopSize(handle: Long): Pair<Int, Int> {
|
||||||
|
val packed = nativeDesktopSize(handle)
|
||||||
|
if (packed == 0L) return 0 to 0
|
||||||
|
val w = ((packed ushr 32) and 0xFFFF_FFFF).toInt()
|
||||||
|
val h = (packed and 0xFFFF_FFFF).toInt()
|
||||||
|
return w to h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 帧头拆包(v5)。从 [RustBridge] 里独立出来的原因:**这个文件里的字节偏移必须能在
|
||||||
|
* JVM 单测里被验证**,而 `RustBridge` object 带 `init { System.loadLibrary }`,
|
||||||
|
* 测试里一碰它就抛 `UnsatisfiedLinkError`(本机无 .so)。顶层 object 无 native、无 init,
|
||||||
|
* 构造嵌套类 [RustBridge.Frame] 也不触发外层 object 初始化——拆包逻辑因此可测。
|
||||||
|
*
|
||||||
|
* 跨端契约:android-ffi/src/lib.rs 按 buf[0]/[1..9]/[9..17]/[17..21]/[21..25]/[25]/
|
||||||
|
* [26..28]/[28..30] 写字节,这里按**同样的偏移**读。任一侧挪一位,两端都编译得过、
|
||||||
|
* 真机也连得上——只是标记画错位置或永不显示(静默失败)。golden bytes 对拍锁死它,
|
||||||
|
* 见 RustBridgeTest。
|
||||||
|
*/
|
||||||
|
internal object FrameCodec {
|
||||||
|
|
||||||
|
// v5:[1 kf][8 seq][8 ts][4 w][4 h][1 has_cursor][2 x i16][2 y i16] = 30
|
||||||
|
// (与 android-ffi/src/lib.rs 的 FRAME_HEADER_LEN 同步;新增字段一律追加尾部,
|
||||||
|
// 旧偏移 0/1/9/17/21 不移动——R-5 同源检查过:两处必须同改)
|
||||||
|
const val FRAME_HEADER_LEN = 30
|
||||||
|
|
||||||
|
/** 拆一帧。短于定长头 → null(截断/旧布局防御)。 */
|
||||||
|
fun parse(raw: ByteArray): RustBridge.Frame? {
|
||||||
|
if (raw.size < FRAME_HEADER_LEN) return null
|
||||||
|
val hasCursor = raw[25] != 0.toByte()
|
||||||
|
return RustBridge.Frame(
|
||||||
|
keyframe = raw[0] != 0.toByte(),
|
||||||
|
seq = readLong(raw, 1),
|
||||||
|
tsMs = readLong(raw, 9),
|
||||||
|
width = readInt(raw, 17),
|
||||||
|
height = readInt(raw, 21),
|
||||||
|
// has=0 时坐标字节恒为 0 且无意义,绝不能读成 (0,0)——那会被画到左上角
|
||||||
|
cursor = if (hasCursor) Pair(readI16(raw, 26), readI16(raw, 28)) else null,
|
||||||
|
payload = raw.copyOfRange(FRAME_HEADER_LEN, raw.size),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readLong(b: ByteArray, off: Int): Long {
|
||||||
|
var v = 0L
|
||||||
|
for (i in 0 until 8) v = v or ((b[off + i].toLong() and 0xFF) shl (8 * i))
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readInt(b: ByteArray, off: Int): Int {
|
||||||
|
return (b[off].toInt() and 0xFF) or
|
||||||
|
((b[off + 1].toInt() and 0xFF) shl 8) or
|
||||||
|
((b[off + 2].toInt() and 0xFF) shl 16) or
|
||||||
|
((b[off + 3].toInt() and 0xFF) shl 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 little-endian **有符号** 16 位读(协议 cursor 是 i16;读成无符号会让负坐标穿透) */
|
||||||
|
private fun readI16(b: ByteArray, off: Int): Int {
|
||||||
|
return ((b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)).toShort().toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android KeyEvent.KEYCODE_* → Windows PS/2 Set-1 扫描码 + extended 标志。
|
||||||
|
*
|
||||||
|
* **扫描码值直接取自桌面 viewer 自己的映射(`viewer/src/keymap.rs`,项目内部事实)**,
|
||||||
|
* 不是凭记忆写的;Android 侧一律用命名常量 `KeyEvent.KEYCODE_*` 引用,
|
||||||
|
* 数值由编译器解析(已用 `javap -constants android.jar` 验证这些常量在 API 34 存在)。
|
||||||
|
*
|
||||||
|
* 分工(协议 v3 之后):
|
||||||
|
* - 可打印字符优先走 **Text 通道**(`KeyEvent.unicodeChar` / IME `commitText`),
|
||||||
|
* 布局无关、中文可用;
|
||||||
|
* - 本表服务控制键(Enter/Tab/Backspace/方向键/功能键)与物理键盘,
|
||||||
|
* 走 `KeyDown/KeyUp` 扫描码通道。
|
||||||
|
*
|
||||||
|
* 不做 Shift/Ctrl 组合状态跟踪:软键盘的大写字母由 IME 直接 commit 字符,
|
||||||
|
* 不需要本地修饰键语义。物理键盘组合键(Ctrl+C 等)登记为后续事项。
|
||||||
|
*/
|
||||||
|
data class WinKey(val scancode: Int, val extended: Boolean)
|
||||||
|
|
||||||
|
object WinScancode {
|
||||||
|
|
||||||
|
fun of(keyCode: Int): WinKey? = when (keyCode) {
|
||||||
|
// 字母区(Set-1 make code,同 keymap.rs)
|
||||||
|
KeyEvent.KEYCODE_A -> WinKey(0x1E, false)
|
||||||
|
KeyEvent.KEYCODE_B -> WinKey(0x30, false)
|
||||||
|
KeyEvent.KEYCODE_C -> WinKey(0x2E, false)
|
||||||
|
KeyEvent.KEYCODE_D -> WinKey(0x20, false)
|
||||||
|
KeyEvent.KEYCODE_E -> WinKey(0x12, false)
|
||||||
|
KeyEvent.KEYCODE_F -> WinKey(0x21, false)
|
||||||
|
KeyEvent.KEYCODE_G -> WinKey(0x22, false)
|
||||||
|
KeyEvent.KEYCODE_H -> WinKey(0x23, false)
|
||||||
|
KeyEvent.KEYCODE_I -> WinKey(0x17, false)
|
||||||
|
KeyEvent.KEYCODE_J -> WinKey(0x24, false)
|
||||||
|
KeyEvent.KEYCODE_K -> WinKey(0x25, false)
|
||||||
|
KeyEvent.KEYCODE_L -> WinKey(0x26, false)
|
||||||
|
KeyEvent.KEYCODE_M -> WinKey(0x32, false)
|
||||||
|
KeyEvent.KEYCODE_N -> WinKey(0x31, false)
|
||||||
|
KeyEvent.KEYCODE_O -> WinKey(0x18, false)
|
||||||
|
KeyEvent.KEYCODE_P -> WinKey(0x19, false)
|
||||||
|
KeyEvent.KEYCODE_Q -> WinKey(0x10, false)
|
||||||
|
KeyEvent.KEYCODE_R -> WinKey(0x13, false)
|
||||||
|
KeyEvent.KEYCODE_S -> WinKey(0x1F, false)
|
||||||
|
KeyEvent.KEYCODE_T -> WinKey(0x14, false)
|
||||||
|
KeyEvent.KEYCODE_U -> WinKey(0x16, false)
|
||||||
|
KeyEvent.KEYCODE_V -> WinKey(0x2F, false)
|
||||||
|
KeyEvent.KEYCODE_W -> WinKey(0x11, false)
|
||||||
|
KeyEvent.KEYCODE_X -> WinKey(0x2D, false)
|
||||||
|
KeyEvent.KEYCODE_Y -> WinKey(0x15, false)
|
||||||
|
KeyEvent.KEYCODE_Z -> WinKey(0x2C, false)
|
||||||
|
|
||||||
|
// 数字主键区(1..9, 0;Android keycode 不连续,逐个写)
|
||||||
|
KeyEvent.KEYCODE_1 -> WinKey(0x02, false)
|
||||||
|
KeyEvent.KEYCODE_2 -> WinKey(0x03, false)
|
||||||
|
KeyEvent.KEYCODE_3 -> WinKey(0x04, false)
|
||||||
|
KeyEvent.KEYCODE_4 -> WinKey(0x05, false)
|
||||||
|
KeyEvent.KEYCODE_5 -> WinKey(0x06, false)
|
||||||
|
KeyEvent.KEYCODE_6 -> WinKey(0x07, false)
|
||||||
|
KeyEvent.KEYCODE_7 -> WinKey(0x08, false)
|
||||||
|
KeyEvent.KEYCODE_8 -> WinKey(0x09, false)
|
||||||
|
KeyEvent.KEYCODE_9 -> WinKey(0x0A, false)
|
||||||
|
KeyEvent.KEYCODE_0 -> WinKey(0x0B, false)
|
||||||
|
|
||||||
|
// 符号区(US 布局物理位置,同 keymap.rs)
|
||||||
|
KeyEvent.KEYCODE_SPACE -> WinKey(0x39, false)
|
||||||
|
KeyEvent.KEYCODE_ENTER -> WinKey(0x1C, false)
|
||||||
|
KeyEvent.KEYCODE_DEL -> WinKey(0x0E, false) // Backspace
|
||||||
|
KeyEvent.KEYCODE_FORWARD_DEL -> WinKey(0x53, true) // Delete(扩展)
|
||||||
|
KeyEvent.KEYCODE_TAB -> WinKey(0x0F, false)
|
||||||
|
KeyEvent.KEYCODE_ESCAPE -> WinKey(0x01, false)
|
||||||
|
KeyEvent.KEYCODE_CAPS_LOCK -> WinKey(0x3A, false)
|
||||||
|
KeyEvent.KEYCODE_GRAVE -> WinKey(0x29, false)
|
||||||
|
KeyEvent.KEYCODE_MINUS -> WinKey(0x0C, false)
|
||||||
|
KeyEvent.KEYCODE_EQUALS -> WinKey(0x0D, false)
|
||||||
|
KeyEvent.KEYCODE_LEFT_BRACKET -> WinKey(0x1A, false)
|
||||||
|
KeyEvent.KEYCODE_RIGHT_BRACKET -> WinKey(0x1B, false)
|
||||||
|
KeyEvent.KEYCODE_BACKSLASH -> WinKey(0x2B, false)
|
||||||
|
KeyEvent.KEYCODE_SEMICOLON -> WinKey(0x27, false)
|
||||||
|
KeyEvent.KEYCODE_APOSTROPHE -> WinKey(0x28, false)
|
||||||
|
KeyEvent.KEYCODE_COMMA -> WinKey(0x33, false)
|
||||||
|
KeyEvent.KEYCODE_PERIOD -> WinKey(0x34, false)
|
||||||
|
KeyEvent.KEYCODE_SLASH -> WinKey(0x35, false)
|
||||||
|
|
||||||
|
// 修饰键
|
||||||
|
KeyEvent.KEYCODE_SHIFT_LEFT -> WinKey(0x2A, false)
|
||||||
|
// 右 Shift **不是**扩展键:微软 Keyboard Input Overview 明确
|
||||||
|
// "The right-hand Shift key is not considered an extended key either;
|
||||||
|
// it has a separate scan code (0x36) instead."(本会话实读该页核实)
|
||||||
|
KeyEvent.KEYCODE_SHIFT_RIGHT -> WinKey(0x36, false)
|
||||||
|
KeyEvent.KEYCODE_CTRL_LEFT -> WinKey(0x1D, false)
|
||||||
|
KeyEvent.KEYCODE_CTRL_RIGHT -> WinKey(0x1D, true)
|
||||||
|
KeyEvent.KEYCODE_ALT_LEFT -> WinKey(0x38, false)
|
||||||
|
KeyEvent.KEYCODE_ALT_RIGHT -> WinKey(0x38, true)
|
||||||
|
KeyEvent.KEYCODE_META_LEFT -> WinKey(0x5B, true)
|
||||||
|
KeyEvent.KEYCODE_META_RIGHT -> WinKey(0x5C, true)
|
||||||
|
|
||||||
|
// 导航区(全部扩展键)
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> WinKey(0x48, true)
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> WinKey(0x50, true)
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> WinKey(0x4B, true)
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> WinKey(0x4D, true)
|
||||||
|
KeyEvent.KEYCODE_MOVE_HOME -> WinKey(0x47, true)
|
||||||
|
KeyEvent.KEYCODE_MOVE_END -> WinKey(0x4F, true)
|
||||||
|
KeyEvent.KEYCODE_PAGE_UP -> WinKey(0x49, true)
|
||||||
|
KeyEvent.KEYCODE_PAGE_DOWN -> WinKey(0x51, true)
|
||||||
|
KeyEvent.KEYCODE_INSERT -> WinKey(0x52, true)
|
||||||
|
|
||||||
|
// 功能键(F11/F12 的 0x57/0x58 与 linux-agent keycode_of 注释同源)
|
||||||
|
KeyEvent.KEYCODE_F1 -> WinKey(0x3B, false)
|
||||||
|
KeyEvent.KEYCODE_F2 -> WinKey(0x3C, false)
|
||||||
|
KeyEvent.KEYCODE_F3 -> WinKey(0x3D, false)
|
||||||
|
KeyEvent.KEYCODE_F4 -> WinKey(0x3E, false)
|
||||||
|
KeyEvent.KEYCODE_F5 -> WinKey(0x3F, false)
|
||||||
|
KeyEvent.KEYCODE_F6 -> WinKey(0x40, false)
|
||||||
|
KeyEvent.KEYCODE_F7 -> WinKey(0x41, false)
|
||||||
|
KeyEvent.KEYCODE_F8 -> WinKey(0x42, false)
|
||||||
|
KeyEvent.KEYCODE_F9 -> WinKey(0x43, false)
|
||||||
|
KeyEvent.KEYCODE_F10 -> WinKey(0x44, false)
|
||||||
|
KeyEvent.KEYCODE_F11 -> WinKey(0x57, false)
|
||||||
|
KeyEvent.KEYCODE_F12 -> WinKey(0x58, false)
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3a/U7:连接错误编号→文案映射。
|
||||||
|
* 编号契约的另一端是 Rust(`viewer_core::ConnectErrorKind::code`,
|
||||||
|
* 由 `cargo test -p viewer-core ffi_error_codes_are_stable_and_unique` 锁死),
|
||||||
|
* 这里锁 Kotlin 侧的完整覆盖与文案质量——两端一致才算契约成立。
|
||||||
|
*/
|
||||||
|
class ConnectErrorTextTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun every_known_kind_has_actionable_chinese_text() {
|
||||||
|
val kinds = listOf(
|
||||||
|
ConnectErrorText.KIND_RESOLVE,
|
||||||
|
ConnectErrorText.KIND_TIMEOUT,
|
||||||
|
ConnectErrorText.KIND_REFUSED,
|
||||||
|
ConnectErrorText.KIND_AUTH,
|
||||||
|
ConnectErrorText.KIND_VERSION,
|
||||||
|
ConnectErrorText.KIND_OTHER,
|
||||||
|
ConnectErrorText.KIND_FFI_SELF,
|
||||||
|
)
|
||||||
|
for (k in kinds) {
|
||||||
|
val s = ConnectErrorText.summary(k)
|
||||||
|
assertTrue("kind=$k 必须有文案", !s.isNullOrEmpty())
|
||||||
|
// 项目纪律:用户可见输出中文(文案里至少含一个 CJK 字符)
|
||||||
|
assertTrue("kind=$k 文案必须中文", s!!.any { c -> c.code in 0x4E00..0x9FFF })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun codes_match_ffi_contract() {
|
||||||
|
// 期望值 = android-ffi / viewer-core 的 code() 表(改动必须三处同步)
|
||||||
|
assertEquals(1, ConnectErrorText.KIND_RESOLVE)
|
||||||
|
assertEquals(2, ConnectErrorText.KIND_TIMEOUT)
|
||||||
|
assertEquals(3, ConnectErrorText.KIND_REFUSED)
|
||||||
|
assertEquals(4, ConnectErrorText.KIND_AUTH)
|
||||||
|
assertEquals(5, ConnectErrorText.KIND_VERSION)
|
||||||
|
assertEquals(9, ConnectErrorText.KIND_OTHER)
|
||||||
|
assertEquals(-1, ConnectErrorText.KIND_FFI_SELF)
|
||||||
|
assertEquals(0, ConnectErrorText.KIND_NONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun texts_are_distinct_per_failure_mode() {
|
||||||
|
// 五种可区分失败不能给同一句文案(V-A2 的映射前提)
|
||||||
|
val texts = listOf(
|
||||||
|
ConnectErrorText.KIND_RESOLVE,
|
||||||
|
ConnectErrorText.KIND_TIMEOUT,
|
||||||
|
ConnectErrorText.KIND_REFUSED,
|
||||||
|
ConnectErrorText.KIND_AUTH,
|
||||||
|
ConnectErrorText.KIND_VERSION,
|
||||||
|
).map { ConnectErrorText.summary(it)!! }
|
||||||
|
assertEquals(texts.size, texts.distinct().size)
|
||||||
|
for (t in texts) {
|
||||||
|
assertNotEquals("连接失败", t) // 可分类的不得落兜底文案
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknown_kind_falls_back_without_claiming_a_cause() {
|
||||||
|
// 编号漂移时不猜语义:兜底文案 + 带未知编号(R-8:宁可少说不可说错)
|
||||||
|
assertNull(ConnectErrorText.summary(42))
|
||||||
|
val line = ConnectErrorText.firstLine(42)
|
||||||
|
assertTrue("实际: $line", line.contains("42"))
|
||||||
|
assertTrue("实际: $line", line.startsWith("连接失败"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun firstLine_uses_summary_for_known_kinds() {
|
||||||
|
for (k in listOf(1, 2, 3, 4, 5, 9, -1)) {
|
||||||
|
assertEquals(ConnectErrorText.summary(k), ConnectErrorText.firstLine(k))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4b/V-D1:光标标记映射的纯逻辑(协议 v5)。
|
||||||
|
*
|
||||||
|
* 这些断言对应验收判据,不是「跑通就算」:
|
||||||
|
* - V-D1「标记位置=映射值」→ [center_maps_to_view_center] / [full_extent_linear_map]
|
||||||
|
* - 「(0,0) 是合法位置」→ [origin_maps_not_treated_as_none](协议用 Option 的全部理由)
|
||||||
|
* - 跨信任边界自证 → 越界与负值一律 null(Linux 后端未实现/被篡改时的防线)
|
||||||
|
*/
|
||||||
|
class CursorMarkerTest {
|
||||||
|
|
||||||
|
// ---- 核心等比映射 ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 横屏典型几何:桌面 1920x1080,视图 1080x607(满宽 letterbox)。
|
||||||
|
* 桌面中心应映射到视图中心。这是 V-D1 的锚点用例——真机 proof 就是把
|
||||||
|
* SetCursorPos 打到桌面中心,再在截图里断言标记簇心在视图中心 ±6px。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun center_maps_to_view_center() {
|
||||||
|
val p = CursorMarker.mapToView(
|
||||||
|
desktopW = 1920, desktopH = 1080,
|
||||||
|
cursor = 960 to 540,
|
||||||
|
viewW = 1080, viewH = 607,
|
||||||
|
)
|
||||||
|
assertNotNull(p)
|
||||||
|
assertEquals(540f, p!!.x, 1f)
|
||||||
|
assertEquals(303.5f, p.y, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 满量程线性映射:桌面右下角 (1919,1079) → 视图右下角附近,
|
||||||
|
* 且不越过视图边界(映射后必须仍在画面内可画)。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun full_extent_linear_map() {
|
||||||
|
val p = CursorMarker.mapToView(1920, 1080, 1919 to 1079, 1080, 607)
|
||||||
|
assertNotNull(p)
|
||||||
|
assertEquals(1080f * 1919 / 1920f, p!!.x, 0.5f)
|
||||||
|
assertEquals(607f * 1079 / 1080f, p.y, 0.5f)
|
||||||
|
assertTrue("映射 x 不得越过视图右边界", p.x < 1080f)
|
||||||
|
assertTrue("映射 y 不得越过视图下边界", p.y < 607f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (0,0) 必须映射成视图左上角,**而不是 null**。
|
||||||
|
* 这条锁住最危险的错误:实现把「左上角」和「没有光标」混为一谈
|
||||||
|
* (比如用 (0,0) 当缺省值再判 ==0 返回 null)。协议专门用 Option 区分二者,
|
||||||
|
* 这层区分在 Kotlin 侧不能被抹平。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun origin_maps_not_treated_as_none() {
|
||||||
|
val p = CursorMarker.mapToView(1920, 1080, 0 to 0, 1080, 607)
|
||||||
|
assertNotNull("左上角是合法光标位置,不得当成无光标丢弃")
|
||||||
|
assertEquals(0f, p!!.x, 0.001f)
|
||||||
|
assertEquals(0f, p.y, 0.001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1:1 场景(视图尺寸恰等于桌面):坐标原样透传。 */
|
||||||
|
@Test
|
||||||
|
fun identity_when_sizes_match() {
|
||||||
|
val p = CursorMarker.mapToView(1280, 720, 640 to 360, 1280, 720)
|
||||||
|
assertEquals(640f, p!!.x, 0.001f)
|
||||||
|
assertEquals(360f, p.y, 0.001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- null 分支:一律「不画」而非画到别处 ----
|
||||||
|
|
||||||
|
/** cursor=null(隐藏/离屏/后端未实现)→ null。 */
|
||||||
|
@Test
|
||||||
|
fun null_cursor_returns_null() {
|
||||||
|
assertNull(CursorMarker.mapToView(1920, 1080, null, 1080, 607))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任一尺寸未就绪(0 或负)→ null,绝不按 0 除得到 NaN/Infinity。
|
||||||
|
* 覆盖四种:视图未测量(width 0,letterbox onMeasure 前)、
|
||||||
|
* 桌面尺寸还没从 AuthOk 取到、以及负值这种异常。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun not_ready_or_degenerate_dimensions_return_null() {
|
||||||
|
assertNull(CursorMarker.mapToView(0, 1080, 10 to 10, 1080, 607)) // 桌面宽未就绪
|
||||||
|
assertNull(CursorMarker.mapToView(1920, 0, 10 to 10, 1080, 607)) // 桌面高未就绪
|
||||||
|
assertNull(CursorMarker.mapToView(1920, 1080, 10 to 10, 0, 607)) // 视图未测量
|
||||||
|
assertNull(CursorMarker.mapToView(1920, 1080, 10 to 10, 1080, 0)) // 视图高 0
|
||||||
|
assertNull(CursorMarker.mapToView(-5, 1080, 10 to 10, 1080, 607)) // 异常负尺寸
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 越界坐标 → null(不画)。这是跨信任边界的自证:被控端 `fold_cursor` 本应折叠掉
|
||||||
|
* 越界值,但协议把对端字节按不可信输入处理——若折叠漏网或被篡改,这里兜住,
|
||||||
|
* 否则标记会画到画面外(甚至映射出一个「看似合理」的错位置)。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun out_of_desktop_bounds_returns_null() {
|
||||||
|
assertNull(CursorMarker.mapToView(1920, 1080, 1920 to 500, 1080, 607)) // 右边界外一列
|
||||||
|
assertNull(CursorMarker.mapToView(1920, 1080, 500 to 1080, 1080, 607)) // 下边界外一行
|
||||||
|
assertNull(CursorMarker.mapToView(1920, 1080, -1 to 500, 1080, 607)) // 负 X(副屏穿透)
|
||||||
|
assertNull(CursorMarker.mapToView(1920, 1080, 500 to -1, 1080, 607)) // 负 Y
|
||||||
|
assertNull(CursorMarker.mapToView(1920, 1080, 3000 to 500, 1080, 607)) // 远超桌面
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恰好界内 (desktopW-1, desktopH-1) 必须画(与上一条配对,钉死 >= 还是 > 的边界)。 */
|
||||||
|
@Test
|
||||||
|
fun exactly_in_bounds_draws() {
|
||||||
|
assertNotNull(CursorMarker.mapToView(1920, 1080, 1919 to 1079, 1080, 607))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4a:方向策略纯逻辑。
|
||||||
|
*
|
||||||
|
* 常量数值不是随手写的——`javap -constants` 对 android-34 android.jar 核实过
|
||||||
|
* (LANDSCAPE=0、PORTRAIT=1、UNSPECIFIED=-1)。这里把它们钉成断言:
|
||||||
|
* 若有人「顺手改成 SENSOR_LANDSCAPE=6」或调换数值,测试先红。
|
||||||
|
*/
|
||||||
|
class OrientationPolicyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun android_constants_pinned_to_verified_values() {
|
||||||
|
assertEquals(0, Orientation.REQUEST_LANDSCAPE)
|
||||||
|
assertEquals(1, Orientation.REQUEST_PORTRAIT)
|
||||||
|
assertEquals(-1, Orientation.REQUEST_UNSPECIFIED)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AUTO 的判定 ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auto_picks_landscape_for_wider_remote() {
|
||||||
|
// 本机被控端就是 1920x1080 —— 这正是 4a 需求的来源
|
||||||
|
assertEquals(Orientation.REQUEST_LANDSCAPE, Orientation.requestFor(OrientationPolicy.AUTO, 1920, 1080))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auto_picks_portrait_for_taller_remote() {
|
||||||
|
assertEquals(Orientation.REQUEST_PORTRAIT, Orientation.requestFor(OrientationPolicy.AUTO, 1080, 1920))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auto_picks_portrait_for_square_and_unknown() {
|
||||||
|
// 方屏没有「更宽」的优势,按竖屏兜底
|
||||||
|
assertEquals(Orientation.REQUEST_PORTRAIT, Orientation.requestFor(OrientationPolicy.AUTO, 1080, 1080))
|
||||||
|
// 尺寸未知(连接前/断开后)必须竖屏,不能让表单页横掉
|
||||||
|
assertEquals(Orientation.REQUEST_PORTRAIT, Orientation.requestFor(OrientationPolicy.AUTO, 0, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forced_policies_ignore_remote_geometry() {
|
||||||
|
for (wh in listOf(1920 to 1080, 1080 to 1920, 0 to 0)) {
|
||||||
|
assertEquals(Orientation.REQUEST_LANDSCAPE,
|
||||||
|
Orientation.requestFor(OrientationPolicy.LANDSCAPE, wh.first, wh.second))
|
||||||
|
assertEquals(Orientation.REQUEST_PORTRAIT,
|
||||||
|
Orientation.requestFor(OrientationPolicy.PORTRAIT, wh.first, wh.second))
|
||||||
|
assertEquals(Orientation.REQUEST_UNSPECIFIED,
|
||||||
|
Orientation.requestFor(OrientationPolicy.SYSTEM, wh.first, wh.second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 按钮循环(Q4=A)----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun button_cycles_all_four_in_order() {
|
||||||
|
assertEquals(OrientationPolicy.LANDSCAPE, Orientation.next(OrientationPolicy.AUTO))
|
||||||
|
assertEquals(OrientationPolicy.PORTRAIT, Orientation.next(OrientationPolicy.LANDSCAPE))
|
||||||
|
assertEquals(OrientationPolicy.SYSTEM, Orientation.next(OrientationPolicy.PORTRAIT))
|
||||||
|
assertEquals(OrientationPolicy.AUTO, Orientation.next(OrientationPolicy.SYSTEM))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun four_clicks_return_to_start() {
|
||||||
|
var p = OrientationPolicy.AUTO
|
||||||
|
repeat(4) { p = Orientation.next(p) }
|
||||||
|
assertEquals(OrientationPolicy.AUTO, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun every_policy_has_distinct_nonempty_label() {
|
||||||
|
val labels = OrientationPolicy.values().map { Orientation.label(it) }
|
||||||
|
labels.forEach { assertNotEquals("", it) }
|
||||||
|
assertEquals("四个策略文案必须互不相同", labels.size, labels.toSet().size)
|
||||||
|
// 文案要让用户看出当前状态(含「方向:」前缀),不能只写「自动/横/竖」
|
||||||
|
labels.forEach { assertEquals(true, it.startsWith("方向:")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- prefs 反序列化 ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun from_name_roundtrips_all() {
|
||||||
|
OrientationPolicy.values().forEach {
|
||||||
|
assertEquals(it, Orientation.fromName(it.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun from_name_falls_back_to_auto_for_garbage() {
|
||||||
|
// 未知值不得抛异常(会让 Activity 起不来),必须落回默认 AUTO
|
||||||
|
assertEquals(OrientationPolicy.AUTO, Orientation.fromName(null))
|
||||||
|
assertEquals(OrientationPolicy.AUTO, Orientation.fromName(""))
|
||||||
|
assertEquals(OrientationPolicy.AUTO, Orientation.fromName("DIAGONAL"))
|
||||||
|
// 大小写宽容:早期版本可能存过小写
|
||||||
|
assertEquals(OrientationPolicy.LANDSCAPE, Orientation.fromName("landscape"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3a/U7:退格决策纯函数。原逻辑在 `RemoteInputConnection.deleteBackspaces`
|
||||||
|
* (构造链依赖 android.jar 桩,JVM 里不可测),3a 提取为 [planBackspaces] 后
|
||||||
|
* 首次可单测。期望值对照 2b 真机行为(语义不变,见 RemoteInputConnection 注释)。
|
||||||
|
*/
|
||||||
|
class PlanBackspacesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun zero_zero_is_noop() {
|
||||||
|
assertEquals(BackspaceAction.Noop, planBackspaces(0, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun after_positive_is_unsupported_and_reports_both_counts() {
|
||||||
|
// U3:forward-delete 形态本地无远端真值,必须显式 Unsupported(可见后果),
|
||||||
|
// 不得静默当退格执行——before 值要随形态带出去供 debug 通道显示
|
||||||
|
assertEquals(BackspaceAction.Unsupported(2, 1), planBackspaces(2, 1))
|
||||||
|
assertEquals(BackspaceAction.Unsupported(0, 3), planBackspaces(0, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun before_only_maps_to_backspace_count() {
|
||||||
|
// IME 退格最常见形态(R-9 实测)
|
||||||
|
assertEquals(BackspaceAction.Backspaces(3), planBackspaces(3, 0))
|
||||||
|
assertEquals(BackspaceAction.Backspaces(1), planBackspaces(1, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun count_is_capped_at_limit() {
|
||||||
|
// 防 IME 异常大值刷爆消息队列;截断语义必须发生在计划层(执行层无感知上限)
|
||||||
|
assertEquals(BackspaceAction.Backspaces(64), planBackspaces(64, 0))
|
||||||
|
assertEquals(BackspaceAction.Backspaces(64), planBackspaces(999, 0))
|
||||||
|
assertEquals(BackspaceAction.Backspaces(MAX_BACKSPACE), planBackspaces(1_000_000, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun MAX_BACKSPACE_constant_is_pinned() {
|
||||||
|
// 与 RemoteInputConnection 的 companion 原值一致(提取时不得改数)
|
||||||
|
assertEquals(64, MAX_BACKSPACE)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,172 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3b/V-B1:相对鼠标的纯逻辑(协议 v4)。
|
||||||
|
*
|
||||||
|
* 这些断言对应验收判据,不是「跑通就算」:
|
||||||
|
* - V-B1b「慢速小幅滑动不丢位移」→ [accumulatorKeepsSubPixelRemainders]
|
||||||
|
* - V-B1c「撞边后回滑立即响应」→ 累积器只做取整、不做补偿([accumulatorDoesNotBankLostSteps])
|
||||||
|
*/
|
||||||
|
class RelPointerTest {
|
||||||
|
|
||||||
|
// ---- 换算:视图像素 → 桌面像素 ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sensitivity_one_maps_full_view_width_to_full_desktop_width() {
|
||||||
|
// Q4=A 的语义定义:1.0 = 手指扫过视图全宽 = 指针走完桌面全宽
|
||||||
|
val d = RelPointer.toDesktopDelta(
|
||||||
|
viewDeltaPx = 1080f, // 视图全宽(1080x607 letterbox)
|
||||||
|
viewExtentPx = 1080,
|
||||||
|
remoteExtentPx = 1920, // 远端桌面宽
|
||||||
|
sensitivity = 1.0f,
|
||||||
|
)
|
||||||
|
assertEquals(1920f, d, 0.01f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sensitivity_scales_linearly() {
|
||||||
|
val base = RelPointer.toDesktopDelta(100f, 1080, 1920, 1.0f)
|
||||||
|
val twice = RelPointer.toDesktopDelta(100f, 1080, 1920, 2.0f)
|
||||||
|
assertEquals(base * 2f, twice, 0.01f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negative_delta_stays_negative() {
|
||||||
|
// 向左滑必须得到负增量(绝对坐标是无符号的,相对通道是唯一有符号路径)
|
||||||
|
val d = RelPointer.toDesktopDelta(-54f, 1080, 1920, 1.0f)
|
||||||
|
assertTrue("向左滑应得到负增量,实际 $d", d < 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invalid_extents_yield_zero_not_garbage() {
|
||||||
|
// 视图未测量 / 远端尺寸未取到时必须不动,而不是按错误比例乱飞
|
||||||
|
assertEquals(0f, RelPointer.toDesktopDelta(50f, 0, 1920, 1.0f), 0f)
|
||||||
|
assertEquals(0f, RelPointer.toDesktopDelta(50f, 1080, 0, 1.0f), 0f)
|
||||||
|
assertEquals(0f, RelPointer.toDesktopDelta(0f, 1080, 1920, 1.0f), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sensitivity_is_clamped_to_documented_range() {
|
||||||
|
assertEquals(RelPointer.MIN_SENSITIVITY, RelPointer.clampSensitivity(0.01f), 0f)
|
||||||
|
assertEquals(RelPointer.MAX_SENSITIVITY, RelPointer.clampSensitivity(99f), 0f)
|
||||||
|
assertEquals(1.5f, RelPointer.clampSensitivity(1.5f), 0f)
|
||||||
|
// 边界常量本身必须与方案 Q4=A 一致
|
||||||
|
assertEquals(0.25f, RelPointer.MIN_SENSITIVITY, 0f)
|
||||||
|
assertEquals(3.0f, RelPointer.MAX_SENSITIVITY, 0f)
|
||||||
|
assertEquals(1.0f, RelPointer.DEFAULT_SENSITIVITY, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 累积取整:V-B1b ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun accumulatorKeepsSubPixelRemainders() {
|
||||||
|
// 每次只移动 0.3 桌面像素:逐次 toInt() 会全被吞掉(位移静默丢失)。
|
||||||
|
// 累积器必须在攒够 1 像素时发出,且总量守恒。
|
||||||
|
val acc = RelAccumulator()
|
||||||
|
var total = 0
|
||||||
|
repeat(10) {
|
||||||
|
acc.take(0.3f, 0f)?.let { total += it.first }
|
||||||
|
}
|
||||||
|
// 10 * 0.3 = 3.0 → 必须发出 3 像素,而不是 0
|
||||||
|
assertEquals("慢速滑动不得丢位移", 3, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun accumulatorDoesNotEmitBelowHalfPixel() {
|
||||||
|
// 小于 0.5 的余量四舍五入后是 0 → 不发(否则会产生大量零位移消息)。
|
||||||
|
// 注意阈值是 0.5 而不是 1.0:累积器用 roundToInt 而非 toInt,
|
||||||
|
// 0.8 会先发出 1 并把 -0.2 留作余数(自我修正,长期总量守恒)。
|
||||||
|
val acc = RelAccumulator()
|
||||||
|
assertNull("0.4 四舍五入为 0,不得发送", acc.take(0.4f, 0.4f))
|
||||||
|
// 再攒 0.4 → 合计 0.8,四舍五入为 1,此时必须发出
|
||||||
|
val d = acc.take(0.4f, 0.4f)
|
||||||
|
assertEquals(1, d!!.first)
|
||||||
|
assertEquals(1, d.second)
|
||||||
|
// 余数 -0.2:紧接着的 0.2 会被抵消成 0,不再发送(证明余数被正确保留)
|
||||||
|
assertNull(acc.take(0.2f, 0.2f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun accumulatorRoundsNegativeValuesAwayFromZero() {
|
||||||
|
// -0.7 应取 -1(向零截断会得到 0,反向慢滑就丢了)
|
||||||
|
val acc = RelAccumulator()
|
||||||
|
val d = acc.take(-0.7f, 0f)
|
||||||
|
assertEquals(-1, d!!.first)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun accumulatorAxesAreIndependent() {
|
||||||
|
// 斜向慢滑:X 攒够时 Y 还没攒够,不能因为 Y 不够就把 X 也拖住
|
||||||
|
val acc = RelAccumulator()
|
||||||
|
val d = acc.take(1.0f, 0.2f)
|
||||||
|
assertEquals(1, d!!.first)
|
||||||
|
assertEquals(0, d.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun accumulatorResetDropsRemainder() {
|
||||||
|
// 手势/会话结束时清空:上一段的余数不该漏进下一段
|
||||||
|
val acc = RelAccumulator()
|
||||||
|
acc.take(0.6f, 0.6f)
|
||||||
|
acc.reset()
|
||||||
|
assertNull(acc.take(0.3f, 0.3f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun accumulatorDoesNotBankLostSteps() {
|
||||||
|
// V-B1c:撞边时被 clamp 丢掉的位移**不回补**。累积器只负责取整,
|
||||||
|
// 不负责补偿——否则撞边后回滑会先"空转"一段,违反「立即响应」。
|
||||||
|
val acc = RelAccumulator()
|
||||||
|
acc.take(1.0f, 0f) // 发出 +1
|
||||||
|
val back = acc.take(-1.0f, 0f)
|
||||||
|
assertEquals("回滑必须立即发出,不得因补偿而空转", -1, back!!.first)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun accumulatorClampsToProtocolRange() {
|
||||||
|
// 协议字段是 i16;越界必须被 clamp 而不是回绕(回绕会让指针跳到屏幕另一侧)
|
||||||
|
val acc = RelAccumulator()
|
||||||
|
val d = acc.take(100_000f, -100_000f)
|
||||||
|
assertEquals(32767, d!!.first)
|
||||||
|
assertEquals(-32768, d.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 长按右键:V-B3 ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun longPress_fires_at_threshold_when_still() {
|
||||||
|
assertTrue(LongPressPolicy.shouldFire(500L, 0f, 30f, false))
|
||||||
|
assertTrue(LongPressPolicy.shouldFire(900L, 2f, 30f, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun longPress_does_not_fire_too_early() {
|
||||||
|
assertFalse(LongPressPolicy.shouldFire(499L, 0f, 30f, false))
|
||||||
|
assertFalse(LongPressPolicy.shouldFire(0L, 0f, 30f, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun longPress_does_not_fire_after_moving() {
|
||||||
|
// V-B3「拖动窗口时不弹菜单」:超过 slop 一律不算长按
|
||||||
|
assertFalse(LongPressPolicy.shouldFire(800L, 31f, 30f, false))
|
||||||
|
assertFalse(LongPressPolicy.shouldFire(800L, 200f, 30f, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun longPress_does_not_fire_with_two_fingers() {
|
||||||
|
// 双指有自己的右键路径,长按不能叠加(否则一次双指轻点会出两个右键)
|
||||||
|
assertFalse(LongPressPolicy.shouldFire(900L, 0f, 30f, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun long_press_threshold_is_pinned_to_decision() {
|
||||||
|
// Q5=A:500ms,改动必须显式经过这条断言
|
||||||
|
assertEquals(500L, LongPressPolicy.LONG_PRESS_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4b:[FrameCodec] 帧头拆包的**跨端字节契约**测试(golden bytes)。
|
||||||
|
*
|
||||||
|
* 为什么值这个钱:android-ffi/src/lib.rs 按 buf[0]/[1..9]/[9..17]/[17..21]/[21..25]/
|
||||||
|
* [25]/[26..28]/[28..30] 写字节,Kotlin 按同样的偏移读。**任何一侧挪一位**(尤其 v5
|
||||||
|
* 从 25→30 这次),Rust 编译过、Kotlin 也编译过、真机能连上——只是标记画到错误位置
|
||||||
|
* 或永远不显示,静默失败(全局规则:核过 Rust 一端不等于核过 Kotlin 另一端)。
|
||||||
|
*
|
||||||
|
* 期望值用 goldenHeader 按 Rust 侧写入布局**手算**生成(`to_le_bytes()` 的逐字节结果)。
|
||||||
|
* Rust 侧若改偏移或改字节序,这里的对拍就红。这是唯一能在 JVM 里锁住 JNI 二进制布局的
|
||||||
|
* 手段(真机跑不了单测)。拆包逻辑之所以住在顶层 [FrameCodec] 而不是 RustBridge object,
|
||||||
|
* 正因为后者带 loadLibrary 的 init,测试里不可达。
|
||||||
|
*/
|
||||||
|
class RustBridgeTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「IDR、seq=0x0102030405060708、ts=0x0011223344556677、桌面 1920x1080、
|
||||||
|
* 光标 has=x/y」的 30 字节头,逐字节 = android-ffi 的写入布局。
|
||||||
|
*/
|
||||||
|
private fun goldenHeader(hasCursor: Int, x: Int, y: Int): ByteArray {
|
||||||
|
val bytes = listOf(
|
||||||
|
0x01, // [0] keyframe = true
|
||||||
|
// [1..9] seq = 0x0102030405060708 LE
|
||||||
|
0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01,
|
||||||
|
// [9..17] ts = 0x0011223344556677 LE
|
||||||
|
0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00,
|
||||||
|
// [17..21] width = 1920 (0x0780) LE
|
||||||
|
0x80, 0x07, 0x00, 0x00,
|
||||||
|
// [21..25] height = 1080 (0x0438) LE
|
||||||
|
0x38, 0x04, 0x00, 0x00,
|
||||||
|
// [25] has_cursor
|
||||||
|
hasCursor,
|
||||||
|
// [26..28] cursor.x i16 LE
|
||||||
|
x and 0xFF, (x shr 8) and 0xFF,
|
||||||
|
// [28..30] cursor.y i16 LE
|
||||||
|
y and 0xFF, (y shr 8) and 0xFF,
|
||||||
|
)
|
||||||
|
return bytes.map { it.toByte() }.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 光标 Some((960,540)):逐字段核对,锁死 [25]/[26..28]/[28..30] 三个新偏移。 */
|
||||||
|
@Test
|
||||||
|
fun parses_v5_header_with_cursor() {
|
||||||
|
val payload = byteArrayOf(0xAA.toInt().toByte(), 0xBB.toInt().toByte(), 0xCC.toInt().toByte())
|
||||||
|
val raw = goldenHeader(hasCursor = 1, x = 960, y = 540) + payload
|
||||||
|
val f = FrameCodec.parse(raw)!!
|
||||||
|
assertEquals(true, f.keyframe)
|
||||||
|
assertEquals(0x0102030405060708L, f.seq)
|
||||||
|
assertEquals(0x0011223344556677L, f.tsMs)
|
||||||
|
assertEquals(1920, f.width)
|
||||||
|
assertEquals(1080, f.height)
|
||||||
|
assertEquals(960 to 540, f.cursor)
|
||||||
|
assertArrayEquals(payload, f.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* has_cursor=0 时**即便坐标字节是全 1 位模式(0xFFFF = -1)也必须读成 null**。
|
||||||
|
* 这条锁住 `if (hasCursor)` 的判断:若误判成「非零即有」,(-1,-1) 会穿透成一个
|
||||||
|
* 看似合法的错位置——正是协议用独立 has 字节而非「0 表示无」的原因。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun has_cursor_zero_means_none_regardless_of_coord_bytes() {
|
||||||
|
// 故意把 x/y 字节填成 -1 的位模式(0xFFFF)
|
||||||
|
val raw = goldenHeader(hasCursor = 0, x = 0xFFFF, y = 0xFFFF) + byteArrayOf(0x00)
|
||||||
|
val f = FrameCodec.parse(raw)!!
|
||||||
|
assertNull("has=0 时坐标字节无意义,必须 null", f.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 负光标坐标(有符号 i16)必须正确读回——agent 折叠后不该出现,但拆包层要忠实。 */
|
||||||
|
@Test
|
||||||
|
fun reads_signed_cursor_coords() {
|
||||||
|
val raw = goldenHeader(hasCursor = 1, x = (-1) and 0xFFFF, y = (-32768) and 0xFFFF) + byteArrayOf(0)
|
||||||
|
val f = FrameCodec.parse(raw)!!
|
||||||
|
assertEquals(-1 to -32768, f.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 光标坐标取 i16 两端边界 (32767, -32768) 往返,证明 readI16 符号扩展正确。 */
|
||||||
|
@Test
|
||||||
|
fun cursor_i16_extremes() {
|
||||||
|
val raw = goldenHeader(hasCursor = 1, x = 32767, y = -32768) + byteArrayOf(0)
|
||||||
|
val f = FrameCodec.parse(raw)!!
|
||||||
|
assertEquals(32767 to -32768, f.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 短于 30 字节的缓冲(旧 25 字节布局或截断帧)必须返回 null,不能切片越界崩溃。 */
|
||||||
|
@Test
|
||||||
|
fun rejects_truncated_buffer() {
|
||||||
|
assertNull(FrameCodec.parse(ByteArray(29)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恰好 30 字节(空载荷)是合法的最小帧;载荷长度为 0 而非 null。 */
|
||||||
|
@Test
|
||||||
|
fun accepts_exact_header_with_empty_payload() {
|
||||||
|
val raw = goldenHeader(hasCursor = 1, x = 100, y = 200)
|
||||||
|
val f = FrameCodec.parse(raw)!!
|
||||||
|
assertEquals(100 to 200, f.cursor)
|
||||||
|
assertEquals(0, f.payload.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3a/U7 首批 JVM 单测:扫描码映射表(此前只能真机点验)。
|
||||||
|
*
|
||||||
|
* `KeyEvent.KEYCODE_*` 是 Java 编译期常量(static final int),引用会被内联进
|
||||||
|
* 测试字节码——JVM 单测不触发 android.jar 桩类加载,所以这里不需要 Robolectric。
|
||||||
|
* 期望值来自项目内部权威源 `viewer/src/keymap.rs`(同 R-1:不信记忆,对照项目已有用法)。
|
||||||
|
*/
|
||||||
|
class WinScancodeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enter_is_make_0x1C_not_extended() {
|
||||||
|
assertEquals(WinKey(0x1C, false), WinScancode.of(KeyEvent.KEYCODE_ENTER))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun backspace_is_0x0E() {
|
||||||
|
assertEquals(WinKey(0x0E, false), WinScancode.of(KeyEvent.KEYCODE_DEL))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forward_delete_is_0x53_extended() {
|
||||||
|
assertEquals(WinKey(0x53, true), WinScancode.of(KeyEvent.KEYCODE_FORWARD_DEL))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun arrows_are_extended() {
|
||||||
|
assertEquals(WinKey(0x48, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_UP))
|
||||||
|
assertEquals(WinKey(0x50, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_DOWN))
|
||||||
|
assertEquals(WinKey(0x4B, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_LEFT))
|
||||||
|
assertEquals(WinKey(0x4D, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_RIGHT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun right_shift_is_NOT_extended_with_its_own_scancode() {
|
||||||
|
// 微软 Keyboard Input Overview:右 Shift 不是扩展键,它有独立扫描码 0x36
|
||||||
|
// (2b 的 D4 偏差修正,回归锁死)
|
||||||
|
assertEquals(WinKey(0x36, false), WinScancode.of(KeyEvent.KEYCODE_SHIFT_RIGHT))
|
||||||
|
assertEquals(WinKey(0x2A, false), WinScancode.of(KeyEvent.KEYCODE_SHIFT_LEFT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun right_ctrl_and_alt_are_extended_shared_scancodes() {
|
||||||
|
assertEquals(WinKey(0x1D, true), WinScancode.of(KeyEvent.KEYCODE_CTRL_RIGHT))
|
||||||
|
assertEquals(WinKey(0x1D, false), WinScancode.of(KeyEvent.KEYCODE_CTRL_LEFT))
|
||||||
|
assertEquals(WinKey(0x38, true), WinScancode.of(KeyEvent.KEYCODE_ALT_RIGHT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun letters_and_digits_match_keymap_rs() {
|
||||||
|
// 抽查与 keymap.rs 交集的字母/数字(Set-1 make code)
|
||||||
|
assertEquals(WinKey(0x1E, false), WinScancode.of(KeyEvent.KEYCODE_A))
|
||||||
|
assertEquals(WinKey(0x2C, false), WinScancode.of(KeyEvent.KEYCODE_Z))
|
||||||
|
assertEquals(WinKey(0x02, false), WinScancode.of(KeyEvent.KEYCODE_1))
|
||||||
|
assertEquals(WinKey(0x0B, false), WinScancode.of(KeyEvent.KEYCODE_0))
|
||||||
|
assertEquals(WinKey(0x39, false), WinScancode.of(KeyEvent.KEYCODE_SPACE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unmapped_and_unknown_keycodes_return_null() {
|
||||||
|
// KEYCODE_UNKNOWN=0:软键盘可打印字符的形态,必须落 null 让调用方走 Text 通道,
|
||||||
|
// 不能兜底成任意扫描码(2b D13 的根因之一)
|
||||||
|
assertNull(WinScancode.of(KeyEvent.KEYCODE_UNKNOWN))
|
||||||
|
assertNull(WinScancode.of(0))
|
||||||
|
assertNull(WinScancode.of(-1))
|
||||||
|
assertNull(WinScancode.of(9999))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun f_keys_pinned_until_u6_physical_keyboard_verification() {
|
||||||
|
// N5/U6:F1-F10 来自 Set-1 常识与 keymap.rs 交集,未在 Windows 逐一对账;
|
||||||
|
// 这里锁「当前值不变」,防止无声漂移——真机接物理键盘验证后(U6)才允许改期望值
|
||||||
|
assertEquals(WinKey(0x3B, false), WinScancode.of(KeyEvent.KEYCODE_F1))
|
||||||
|
assertEquals(WinKey(0x44, false), WinScancode.of(KeyEvent.KEYCODE_F10))
|
||||||
|
}
|
||||||
|
}
|
||||||
85
apps/android-viewer/build-android.ps1
Normal file
85
apps/android-viewer/build-android.ps1
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# Phase 1 build chain: Rust .so -> jniLibs -> Gradle debug APK -> install -> launch
|
||||||
|
#
|
||||||
|
# Keep this file ASCII-only (PS 5.1 reads BOM-less .ps1 as GBK; see lessons).
|
||||||
|
#
|
||||||
|
# Usage (from apps/android-viewer/):
|
||||||
|
# powershell -ExecutionPolicy Bypass -File build-android.ps1 [-Host_ 192.168.1.5] [-Port 47800] [-Code t] [-SkipRust]
|
||||||
|
# (param is Host_ because $Host is a PowerShell automatic variable)
|
||||||
|
#
|
||||||
|
# Prereqs (Windows dev box):
|
||||||
|
# - Rust + Android targets + cargo-ndk + NDK (see personal docs/baseline.md toolchain table)
|
||||||
|
# - JDK 17 + Gradle 8.14 (paths in $vars below, adjust to your machine)
|
||||||
|
# - local.properties with sdk.dir (gitignored; template: sdk.dir=<android sdk path>)
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Host_ = "192.168.1.5",
|
||||||
|
[int]$Port = 47800,
|
||||||
|
[string]$Code = "t",
|
||||||
|
[switch]$SkipRust
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# repo root = two levels up from this script (apps/android-viewer -> repo)
|
||||||
|
$proj = $PSScriptRoot
|
||||||
|
$repo = (Resolve-Path "$proj\..\..").Path
|
||||||
|
$env_ = "D:\working\environment"
|
||||||
|
$gradle = "D:\working\android\gradle\gradle-8.14.3\bin\gradle.bat"
|
||||||
|
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||||
|
|
||||||
|
$env:RUSTUP_HOME = "$env_\rust"
|
||||||
|
$env:CARGO_HOME = "$env_\cargo"
|
||||||
|
$env:PATH = "$env_\mingw64\bin;$env_\cargo\bin;$env:PATH"
|
||||||
|
$env:ANDROID_HOME = "D:\working\android\sdk"
|
||||||
|
$env:ANDROID_NDK_HOME = "D:\working\android\sdk\ndk\30.0.16248370"
|
||||||
|
$env:GRADLE_USER_HOME = "D:\working\android\gradle-home"
|
||||||
|
$env:JAVA_HOME = "D:\working\java\jdk17"
|
||||||
|
|
||||||
|
if (-not (Test-Path "$proj\local.properties")) {
|
||||||
|
Set-Content "$proj\local.properties" 'sdk.dir=D\:\\working\\android\\sdk' -Encoding ascii
|
||||||
|
Write-Host "generated local.properties (sdk.dir)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipRust) {
|
||||||
|
Write-Host "=== 1/4 cargo ndk: android-ffi (arm64 release) ===" -ForegroundColor Cyan
|
||||||
|
Push-Location $repo
|
||||||
|
# cargo writes progress to stderr; PS 5.1 + ErrorActionPreference=Stop turns
|
||||||
|
# every stderr line into a terminating error. Relax for native calls and
|
||||||
|
# judge by exit code (same for gradle below).
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
cargo ndk -t arm64-v8a build -p android-ffi --release 2>&1 |
|
||||||
|
Select-String "error|Finished|Building"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "cargo ndk build failed (exit $LASTEXITCODE)" }
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 2/4 copy .so into app/libs ===" -ForegroundColor Cyan
|
||||||
|
$so = "$repo\target\aarch64-linux-android\release\libandroidffi.so"
|
||||||
|
if (-not (Test-Path $so)) { throw "missing $so (run without -SkipRust)" }
|
||||||
|
New-Item -ItemType Directory -Force -Path "$proj\app\libs\arm64-v8a" | Out-Null
|
||||||
|
Copy-Item $so "$proj\app\libs\arm64-v8a\libandroidffi.so" -Force
|
||||||
|
Write-Host (" libandroidffi.so: {0:N0} KB" -f ((Get-Item $so).Length/1KB))
|
||||||
|
|
||||||
|
Write-Host "=== 3/4 gradle assembleDebug ===" -ForegroundColor Cyan
|
||||||
|
Push-Location $proj
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
& $gradle :app:assembleDebug --console=plain 2>&1 | Select-Object -Last 25
|
||||||
|
$code = $LASTEXITCODE
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Pop-Location
|
||||||
|
if ($code -ne 0) { throw "gradle build failed (exit $code)" }
|
||||||
|
|
||||||
|
$apk = "$proj\app\build\outputs\apk\debug\app-debug.apk"
|
||||||
|
if (-not (Test-Path $apk)) { throw "apk not found: $apk" }
|
||||||
|
Write-Host (" APK: {0:N0} KB" -f ((Get-Item $apk).Length/1KB))
|
||||||
|
|
||||||
|
Write-Host "=== 4/4 install + launch ===" -ForegroundColor Cyan
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
& $adb install -r $apk
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "install failed (phone must allow USB install)" }
|
||||||
|
& $adb logcat -c | Out-Null
|
||||||
|
& $adb shell am start -n com.verify.remote/.MainActivity `
|
||||||
|
--es host $Host_ --ei port $Port --es code $Code | Out-Null
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Write-Host "launched. watch: adb logcat -s MainActivity:H264Decoder RustBridge:* viewer-core:* AndroidRuntime:E"
|
||||||
|
Write-Host "after 10s capture screen: adb exec-out screencap -p > screen.png"
|
||||||
4
apps/android-viewer/build.gradle.kts
Normal file
4
apps/android-viewer/build.gradle.kts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.9.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.1.20" apply false
|
||||||
|
}
|
||||||
4
apps/android-viewer/gradle.properties
Normal file
4
apps/android-viewer/gradle.properties
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# 固定 JDK 17(不动全局 JAVA_HOME,遵守 2026-10-29 环境约束)
|
||||||
|
org.gradle.java.home=D\:\\working\\java\\jdk17
|
||||||
|
android.useAndroidX=false
|
||||||
|
org.gradle.jvmargs=-Xmx2g
|
||||||
17
apps/android-viewer/settings.gradle.kts
Normal file
17
apps/android-viewer/settings.gradle.kts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "remote-viewer"
|
||||||
|
include(":app")
|
||||||
@ -1,15 +1,44 @@
|
|||||||
//! 通用协议与帧封装:控制消息(bincode + 长度前缀)与视频帧格式。
|
//! 通用协议与帧封装:控制消息(bincode + 长度前缀)与视频帧格式。
|
||||||
|
|
||||||
|
/// P2P 打洞信令协议(信令通道专用,不含中继;三端共享契约)
|
||||||
|
pub mod signaling;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
/// 媒体/控制协议版本。
|
||||||
|
///
|
||||||
|
/// **改动规则**:任何对 `ControlMsg` 或 `VideoFrameHeader` 字段的增删改都必须递增此值。
|
||||||
|
/// 理由:线格式是 bincode(按位置编码,无字段名),新增字段会让旧端解析失败或错位,
|
||||||
|
/// 且失败方式可能是静默的错误数据而非明确报错。递增后由 `Auth` 握手显式拒绝旧端,
|
||||||
|
/// 把"连上了但行为诡异"变成"连不上且提示升级"。
|
||||||
|
///
|
||||||
|
/// - v1:初版(无版本字段,隐式)
|
||||||
|
/// - v2:`Auth`/`AuthOk` 携带版本;帧头增加 `seq`/`keyframe`
|
||||||
|
/// - v3:新增 `InputEvent::Text`(文本注入通道,UTF-16 code unit)。
|
||||||
|
/// `InputEvent` 因携带 `Vec<u16>` 不再是 `Copy`;三端必须同步升级,
|
||||||
|
/// 旧端由 `Auth` 握手显式拒绝。
|
||||||
|
/// - v4:新增 `InputEvent::MouseMoveRel`(相对鼠标,单位=被控端桌面物理像素)。
|
||||||
|
/// bincode 按位置编码枚举判别序号,新增变体会改变其后变体的编号,
|
||||||
|
/// 故同样必须三端同步;旧端由 `Auth` 握手显式拒绝。
|
||||||
|
/// - v5:`VideoFrameHeader` 尾部新增 `cursor`(本帧发送时刻的远端光标位置)。
|
||||||
|
/// 字段追加同样改变线格式(旧端会在反序列化时少读/多读而错位),故必须三端同步;
|
||||||
|
/// 旧端由 `Auth` 握手显式拒绝。语义见 [`VideoFrameHeader::cursor`]。
|
||||||
|
pub const PROTO_VERSION: u16 = 5;
|
||||||
|
|
||||||
/// 控制通道消息(双向)
|
/// 控制通道消息(双向)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum ControlMsg {
|
pub enum ControlMsg {
|
||||||
/// 主控端认证(临时验证码,MVP 明文走 QUIC-TLS 加密通道)
|
/// 主控端认证(临时验证码,MVP 明文走 QUIC-TLS 加密通道)。
|
||||||
Auth { code: String },
|
/// `proto_version` 必须等于 [`PROTO_VERSION`],否则被控端回 `AuthFail` 并拒绝。
|
||||||
AuthOk { width: u32, height: u32 },
|
Auth { code: String, proto_version: u16 },
|
||||||
|
/// 认证通过。`proto_version` 回显被控端的版本,供主控端确认双方一致。
|
||||||
|
AuthOk {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
proto_version: u16,
|
||||||
|
},
|
||||||
AuthFail(String),
|
AuthFail(String),
|
||||||
KeyFrameReq,
|
KeyFrameReq,
|
||||||
Ping(u64),
|
Ping(u64),
|
||||||
@ -27,9 +56,12 @@ pub enum MouseButton {
|
|||||||
Middle,
|
Middle,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 输入事件。鼠标坐标为虚拟桌面归一化 0..=65535;
|
/// 输入事件。鼠标坐标为**被采集显示器**(主屏)归一化 0..=65535
|
||||||
|
/// (协议 v2 语义,v3 未改;`agent/src/input.rs` 的注入空间与此一致,见个人层 lessons R-4);
|
||||||
/// 按键为 Set-1 扫描码,extended 标记扩展键(方向键/Home 等,需要 EXTENDEDKEY 标志)。
|
/// 按键为 Set-1 扫描码,extended 标记扩展键(方向键/Home 等,需要 EXTENDEDKEY 标志)。
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
///
|
||||||
|
/// v3 起本类型不再 `Copy`:`Text` 携带堆上 `Vec<u16>`。需要复制的地方用 `.clone()`。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum InputEvent {
|
pub enum InputEvent {
|
||||||
MouseMove { x: u16, y: u16 },
|
MouseMove { x: u16, y: u16 },
|
||||||
MouseDown(MouseButton),
|
MouseDown(MouseButton),
|
||||||
@ -37,14 +69,89 @@ pub enum InputEvent {
|
|||||||
Wheel { dy: i32 },
|
Wheel { dy: i32 },
|
||||||
KeyDown { scancode: u16, extended: bool },
|
KeyDown { scancode: u16, extended: bool },
|
||||||
KeyUp { scancode: u16, extended: bool },
|
KeyUp { scancode: u16, extended: bool },
|
||||||
|
/// v3 新增:**文本注入通道**。`units` 为 UTF-16 code unit 序列。
|
||||||
|
///
|
||||||
|
/// 为什么不是 `String`:Windows 注入侧(`KEYEVENTF_UNICODE`)与 Java 字符串的
|
||||||
|
/// 原生表示都是 UTF-16 code unit;用 `String` 要走 UTF-8 往返,遇到 IME 提交边界
|
||||||
|
/// 产生的落单代理项会**有损**。`Vec<u16>` 忠实透传,由接收端按 Win32 语义处理。
|
||||||
|
///
|
||||||
|
/// 为什么整段携带、而不是逐 code unit 一条消息:一次 IME 候选确认常是多个汉字;
|
||||||
|
/// 代理对(emoji 等增补平面字符)**必须在同一次注入批次里成对提交**才能被目标
|
||||||
|
/// 应用合成成一个字符,拆开注入必现乱码。
|
||||||
|
///
|
||||||
|
/// 上限 [`MAX_TEXT_UNITS`]:超长输入截断且两侧打警告日志(不静默丢弃)。
|
||||||
|
/// 另一道防线是 [`read_control`] 的 1 MiB 消息长度闸。
|
||||||
|
///
|
||||||
|
/// 日志纪律:中间层与注入端只允许打印 `units.len()`,**不得**打印用户输入内容。
|
||||||
|
Text { units: Vec<u16> },
|
||||||
|
/// v4 新增:**相对**鼠标位移,单位是**被控端桌面的物理像素**(不是归一化值)。
|
||||||
|
///
|
||||||
|
/// 语义:把远端指针从**当前位置**移动 `(dx, dy)`,越界部分由被控端 clamp 到桌面内。
|
||||||
|
/// 主控端只说「移动多少」,不说「去哪里」——远端指针位置始终是唯一权威,
|
||||||
|
/// 本机真鼠标同时移动也不会造成跳变。
|
||||||
|
///
|
||||||
|
/// 为什么单位是像素而不是归一化增量:`AuthOk` 已下发桌面 `width`/`height`,
|
||||||
|
/// 主控端换算一次即可;被控端拿到像素可直接与 `GetCursorPos` 同尺度相加,
|
||||||
|
/// 少一次取整(每多一次取整就多一个累积误差源)。
|
||||||
|
///
|
||||||
|
/// 为什么是 `i16`:单次触摸事件的位移远小于 32767;用定长整数让线格式保持紧凑。
|
||||||
|
///
|
||||||
|
/// **与 [`InputEvent::MouseMove`] 互斥**:绝对模式发 `MouseMove`,相对模式发本变体,
|
||||||
|
/// 由主控端的模式开关决定,同一会话内不应混用(混用不会出错,但手感会跳)。
|
||||||
|
///
|
||||||
|
/// 注入实现注意:Windows 上**不能**直接 `SendInput(MOUSEEVENTF_MOVE)` 透传
|
||||||
|
/// ——实测该路径被「指针加速」与「DPI 缩放」双重放大(本机 dx=100 实际走了 309px,
|
||||||
|
/// 且倍率随系统设置变化)。正确做法见 `agent/src/input.rs::send_mouse_rel`。
|
||||||
|
MouseMoveRel { dx: i16, dy: i16 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 视频帧头(载荷为 LZ4 压缩的 BGRA 像素,按行紧凑排列)
|
/// [`InputEvent::Text`] 单次携带的 code unit 上限。
|
||||||
|
///
|
||||||
|
/// 与 `read_control` 的 1 MiB 消息闸是两道独立防线:前者防恶意对端把接收端分配打爆,
|
||||||
|
/// 后者防合法但异常长的 IME 提交把注入队列刷爆。4096 unit ≈ 8 KiB,
|
||||||
|
/// 远超正常 IME 一次提交(数十 unit),又足够小使单次 `SendInput` 批量仍可接受。
|
||||||
|
pub const MAX_TEXT_UNITS: usize = 4096;
|
||||||
|
|
||||||
|
/// 把文本输入截断到 [`MAX_TEXT_UNITS`],返回 `(可注入的 units, 是否发生截断)`。
|
||||||
|
///
|
||||||
|
/// 调用方**必须**在截断为真时打印日志(可见后果);本函数只负责机械截断,
|
||||||
|
/// 不自己打印——因为 `common` 同时被主控端与被控端复用,日志口径由调用方决定。
|
||||||
|
pub fn clamp_text_units(units: Vec<u16>) -> (Vec<u16>, bool) {
|
||||||
|
if units.len() > MAX_TEXT_UNITS {
|
||||||
|
let mut trimmed = units;
|
||||||
|
trimmed.truncate(MAX_TEXT_UNITS);
|
||||||
|
(trimmed, true)
|
||||||
|
} else {
|
||||||
|
(units, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 视频帧头。载荷为 H.264 Annex-B 码流(含 00 00 00 01 起始码)。
|
||||||
|
///
|
||||||
|
/// 字段语义:
|
||||||
|
/// - `width` / `height`:被控端桌面尺寸(逻辑尺寸,非编码对齐后的尺寸)
|
||||||
|
/// - `ts_ms`:被控端采集时刻(被控时钟),配合 Ping/Pong 估计的时钟偏移算端到端延迟
|
||||||
|
/// - `seq`:帧序号,从 0 单调递增,每发一帧 +1(含静止桌面的重复帧)。
|
||||||
|
/// 用于丢帧检测与弱网统计;注意它统计的是"发送序",不是"显示序"
|
||||||
|
/// - `keyframe`:本帧是否 IDR。解码端(尤其 MediaCodec)需要它决定是否重置解码器,
|
||||||
|
/// 不必再自己扫 NAL 起始码判断
|
||||||
|
/// - `cursor`(v5):本帧**发送时刻**的远端光标位置,被采集显示器内的**物理像素**
|
||||||
|
/// ((0,0)=采集画面左上角,与 `width`/`height` 同尺度同原点)。
|
||||||
|
/// `None` = 不可用,含三种情况:指针被隐藏(系统未显示光标)、指针在采集显示器
|
||||||
|
/// **之外**(多显示器负坐标区等——采集画面里没有它,主控端画了也是错的)、
|
||||||
|
/// 或被控端平台未实现光标采样。
|
||||||
|
/// 用途:主控端据此在画面上标记指针位置(「看不见远端鼠标」修复);它与 `ts_ms`
|
||||||
|
/// 同刻、同帧到达,不存在标记与画面的相对错位。
|
||||||
|
/// 为什么 `i16`:采集原点固定 (0,0),现实桌面尺寸远小于 32767;被控端已把
|
||||||
|
/// 越界值折叠为 `None`,到达值恒在 `[0, width)×[0, height)` 内。
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct VideoFrameHeader {
|
pub struct VideoFrameHeader {
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub ts_ms: u64,
|
pub ts_ms: u64,
|
||||||
|
pub seq: u64,
|
||||||
|
pub keyframe: bool,
|
||||||
|
pub cursor: Option<(i16, i16)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_u32le<R: AsyncReadExt + Unpin>(r: &mut R) -> Result<u32> {
|
async fn read_u32le<R: AsyncReadExt + Unpin>(r: &mut R) -> Result<u32> {
|
||||||
@ -78,7 +185,9 @@ pub async fn read_control<R: AsyncReadExt + Unpin>(r: &mut R) -> Result<ControlM
|
|||||||
Ok(bincode::deserialize(&buf)?)
|
Ok(bincode::deserialize(&buf)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 编码视频帧:[总长 u32][头长 u32][bincode 头][LZ4 载荷]
|
/// 编码视频帧:[总长 u32][头长 u32][bincode 头][H.264 载荷]
|
||||||
|
///
|
||||||
|
/// `总长` 不含自身这 4 字节,等于 `4 + 头长 + 载荷长`。
|
||||||
pub fn encode_video_frame(header: &VideoFrameHeader, payload: &[u8]) -> Vec<u8> {
|
pub fn encode_video_frame(header: &VideoFrameHeader, payload: &[u8]) -> Vec<u8> {
|
||||||
let hb = bincode::serialize(header).expect("serialize header");
|
let hb = bincode::serialize(header).expect("serialize header");
|
||||||
let total = 4 + hb.len() + payload.len();
|
let total = 4 + hb.len() + payload.len();
|
||||||
@ -90,14 +199,471 @@ pub fn encode_video_frame(header: &VideoFrameHeader, payload: &[u8]) -> Vec<u8>
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 单帧总长上限(256 MiB)。超过即视为协议错误或恶意输入。
|
||||||
|
const MAX_VIDEO_FRAME: usize = 256 << 20;
|
||||||
|
|
||||||
|
/// 判断一段 Annex-B H.264 码流里是否含 IDR 片(NAL 类型 5)。
|
||||||
|
///
|
||||||
|
/// 用途:填充 [`VideoFrameHeader::keyframe`]。
|
||||||
|
/// 为什么不问编码器要这个标志——NVENC 与 openh264 的 API 都能间接推出,
|
||||||
|
/// 但两者规则不同(首帧强制、`force_intra_frame`、GOP 自动 IDR 交织),
|
||||||
|
/// 而**实际输出**是唯一可信来源。直接看码流,两种编码器一套逻辑。
|
||||||
|
///
|
||||||
|
/// 只识别 3/4 字节起始码;`start-code emulation prevention` 由编码器保证,
|
||||||
|
/// 因此不会在 SPS/PPS 载荷里误判出起始码。
|
||||||
|
pub fn h264_contains_idr(payload: &[u8]) -> bool {
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 3 < payload.len() {
|
||||||
|
let sc = if payload[i..].starts_with(&[0, 0, 0, 1]) {
|
||||||
|
4
|
||||||
|
} else if payload[i..].starts_with(&[0, 0, 1]) {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match payload.get(i + sc) {
|
||||||
|
// nal_unit_type 占首字节低 5 位
|
||||||
|
Some(&b) if b & 0x1F == 5 => return true,
|
||||||
|
Some(_) => i += sc + 1,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读一个视频帧。
|
||||||
|
///
|
||||||
|
/// 对端数据一律视为不可信输入:`total` 与 `hlen` 都必须做**上下界**校验,
|
||||||
|
/// 否则构造一个 `total < 4` 的帧就能让接收端在切片处 panic(进程级拒绝服务)。
|
||||||
pub async fn read_video_frame<R: AsyncReadExt + Unpin>(
|
pub async fn read_video_frame<R: AsyncReadExt + Unpin>(
|
||||||
r: &mut R,
|
r: &mut R,
|
||||||
) -> Result<(VideoFrameHeader, Vec<u8>)> {
|
) -> Result<(VideoFrameHeader, Vec<u8>)> {
|
||||||
let total = read_u32le(r).await? as usize;
|
let total = read_u32le(r).await? as usize;
|
||||||
anyhow::ensure!(total < 256 << 20, "video frame too large: {total}");
|
// 下界:至少要容得下「头长字段(4) + 一个非空的 bincode 头」。
|
||||||
|
// 上界:防止对端声明超大长度导致巨量分配。
|
||||||
|
anyhow::ensure!(
|
||||||
|
(4..MAX_VIDEO_FRAME).contains(&total),
|
||||||
|
"video frame length out of range: {total}"
|
||||||
|
);
|
||||||
|
|
||||||
let mut buf = vec![0u8; total];
|
let mut buf = vec![0u8; total];
|
||||||
r.read_exact(&mut buf).await?;
|
r.read_exact(&mut buf).await?;
|
||||||
let hlen = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
|
|
||||||
|
let hlen = u32::from_le_bytes(buf[0..4].try_into().expect("已校验 total >= 4")) as usize;
|
||||||
|
// hlen 必须落在 [1, total-4] 内:0 无法反序列化,超出则切片越界
|
||||||
|
anyhow::ensure!(
|
||||||
|
hlen > 0 && hlen <= total - 4,
|
||||||
|
"video header length invalid: hlen={hlen}, total={total}"
|
||||||
|
);
|
||||||
|
|
||||||
let header: VideoFrameHeader = bincode::deserialize(&buf[4..4 + hlen])?;
|
let header: VideoFrameHeader = bincode::deserialize(&buf[4..4 + hlen])?;
|
||||||
Ok((header, buf[4 + hlen..].to_vec()))
|
Ok((header, buf[4 + hlen..].to_vec()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 把一个已编码的完整帧拆回 `(帧头, 载荷)`。
|
||||||
|
///
|
||||||
|
/// 用途:静止桌面重发时,被控端需要复用上一帧的**载荷**但重建**帧头**
|
||||||
|
/// (`seq` 必须递增、`ts_ms` 必须是本次发送时刻)。直接 `clone` 整个帧会让
|
||||||
|
/// 这两个字段停在上一次发送的时刻,破坏 `seq` 单调递增契约。
|
||||||
|
///
|
||||||
|
/// 只接受本模块 [`encode_video_frame`] 产出的字节;格式不符返回 `Err`。
|
||||||
|
pub fn split_video_frame(frame: &[u8]) -> Result<(VideoFrameHeader, &[u8])> {
|
||||||
|
anyhow::ensure!(frame.len() >= 8, "frame too short: {}", frame.len());
|
||||||
|
let hlen = u32::from_le_bytes(frame[4..8].try_into().expect("已校验 len >= 8")) as usize;
|
||||||
|
anyhow::ensure!(
|
||||||
|
hlen > 0 && hlen <= frame.len() - 8,
|
||||||
|
"header length invalid: hlen={hlen}, len={}",
|
||||||
|
frame.len()
|
||||||
|
);
|
||||||
|
let header: VideoFrameHeader = bincode::deserialize(&frame[8..8 + hlen])?;
|
||||||
|
Ok((header, &frame[8 + hlen..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::duplex;
|
||||||
|
|
||||||
|
fn hdr(seq: u64, keyframe: bool) -> VideoFrameHeader {
|
||||||
|
VideoFrameHeader {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
ts_ms: 12345,
|
||||||
|
seq,
|
||||||
|
keyframe,
|
||||||
|
cursor: Some((640, 480)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 控制消息 ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_roundtrip_preserves_version() {
|
||||||
|
let m = ControlMsg::Auth {
|
||||||
|
code: "123456".into(),
|
||||||
|
proto_version: PROTO_VERSION,
|
||||||
|
};
|
||||||
|
let bytes = encode_control(&m);
|
||||||
|
// 长度前缀 + 载荷
|
||||||
|
let len = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
|
||||||
|
assert_eq!(len, bytes.len() - 4);
|
||||||
|
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
|
||||||
|
match back {
|
||||||
|
ControlMsg::Auth { code, proto_version } => {
|
||||||
|
assert_eq!(code, "123456");
|
||||||
|
assert_eq!(proto_version, PROTO_VERSION);
|
||||||
|
}
|
||||||
|
other => panic!("期望 Auth,实际 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn control_write_read_roundtrip() {
|
||||||
|
let (mut a, mut b) = duplex(64 * 1024);
|
||||||
|
let msgs = [
|
||||||
|
ControlMsg::Auth {
|
||||||
|
code: "x".into(),
|
||||||
|
proto_version: PROTO_VERSION,
|
||||||
|
},
|
||||||
|
ControlMsg::AuthOk {
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
proto_version: PROTO_VERSION,
|
||||||
|
},
|
||||||
|
ControlMsg::KeyFrameReq,
|
||||||
|
ControlMsg::Ping(42),
|
||||||
|
ControlMsg::Pong {
|
||||||
|
t: 1,
|
||||||
|
agent_ts: 2,
|
||||||
|
lost: 3,
|
||||||
|
sent: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for m in &msgs {
|
||||||
|
write_control(&mut a, m).await.unwrap();
|
||||||
|
let got = read_control(&mut b).await.unwrap();
|
||||||
|
assert_eq!(format!("{got:?}"), format!("{m:?}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- v3 文本注入通道 ----
|
||||||
|
|
||||||
|
/// 版本守卫:v3 是文本通道的登记点。v4 之后此断言随版本推进,
|
||||||
|
/// 保留它的作用是「任何一次版本变更都必须显式经过这里」。
|
||||||
|
#[test]
|
||||||
|
fn proto_version_is_three_after_text_event() {
|
||||||
|
assert!(
|
||||||
|
PROTO_VERSION >= 3,
|
||||||
|
"Text 通道自 v3 起存在,版本不得回退(当前 {PROTO_VERSION})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- v4 相对鼠标 ----
|
||||||
|
|
||||||
|
/// 版本守卫:v4 是相对鼠标的登记点。v5 之后此断言随版本推进为下界,
|
||||||
|
/// 与 v3 守卫同规(保留它是「任何一次版本变更都必须显式经过这里」的锚点)。
|
||||||
|
#[test]
|
||||||
|
fn proto_version_is_four_after_rel_move() {
|
||||||
|
assert!(
|
||||||
|
PROTO_VERSION >= 4,
|
||||||
|
"MouseMoveRel 自 v4 起存在,版本不得回退(当前 {PROTO_VERSION})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 版本守卫:v5 是光标字段的登记点(最新一代用精确等号)。
|
||||||
|
#[test]
|
||||||
|
fn proto_version_is_five_after_cursor_field() {
|
||||||
|
assert_eq!(
|
||||||
|
PROTO_VERSION, 5,
|
||||||
|
"新增 VideoFrameHeader::cursor 后必须递增到 5"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 光标字段必须原样往返,含 `None`(隐藏/离屏/未实现三态都折叠为它)与负值边界。
|
||||||
|
/// `None` 是本变体的核心语义:缺它主控端就无法区分「指针在 (0,0)」与「没有指针」。
|
||||||
|
#[test]
|
||||||
|
fn cursor_field_roundtrips_option_and_i16() {
|
||||||
|
for c in [
|
||||||
|
None,
|
||||||
|
Some((0i16, 0i16)),
|
||||||
|
Some((1919, 1079)),
|
||||||
|
Some((-32768, 32767)),
|
||||||
|
Some((i16::MIN, i16::MAX)),
|
||||||
|
] {
|
||||||
|
let mut h = hdr(1, false);
|
||||||
|
h.cursor = c;
|
||||||
|
let payload = b"\x00\x00\x00\x01\x65x";
|
||||||
|
let bytes = encode_video_frame(&h, payload);
|
||||||
|
let hlen = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
|
||||||
|
let back: VideoFrameHeader = bincode::deserialize(&bytes[8..8 + hlen]).unwrap();
|
||||||
|
assert_eq!(back.cursor, c, "光标字段往返失真: {c:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cursor 是**追加在尾部**的字段:既有字段(width/height/ts/seq/keyframe)的
|
||||||
|
/// 解码不受影响,且 `None` 变体让帧头显著变短(证明它没被塞进固定宽度里白占字节)。
|
||||||
|
#[test]
|
||||||
|
fn cursor_is_trailing_field_and_none_is_compact() {
|
||||||
|
let mut with = hdr(9, true);
|
||||||
|
with.cursor = Some((10, 20));
|
||||||
|
let mut without = hdr(9, true);
|
||||||
|
without.cursor = None;
|
||||||
|
let wb = bincode::serialize(&with).unwrap();
|
||||||
|
let wob = bincode::serialize(&without).unwrap();
|
||||||
|
// Some((i16,i16)) 比 None 多 4 字节(两个 i16),1 字节 tag 两边都有
|
||||||
|
assert_eq!(wb.len(), wob.len() + 4, "光标坐标应为 2×i16 定长附加");
|
||||||
|
// 前缀不变:去掉尾部光标字段,其余字节的解码结果完全一致(追加语义)
|
||||||
|
let h: VideoFrameHeader = bincode::deserialize(&wb).unwrap();
|
||||||
|
assert_eq!((h.width, h.height, h.seq, h.keyframe), (1920, 1080, 9, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相对位移必须原样往返,**包含负值**(向左/向上移动)。
|
||||||
|
/// 负值是本变体的核心语义:绝对坐标是无符号的,相对增量必须有符号。
|
||||||
|
#[test]
|
||||||
|
fn mouse_move_rel_roundtrips_signed_deltas() {
|
||||||
|
for (dx, dy) in [(0i16, 0i16), (1, -1), (-32768, 32767), (100, -250)] {
|
||||||
|
let m = ControlMsg::Input(InputEvent::MouseMoveRel { dx, dy });
|
||||||
|
let bytes = encode_control(&m);
|
||||||
|
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
|
||||||
|
match back {
|
||||||
|
ControlMsg::Input(InputEvent::MouseMoveRel { dx: gx, dy: gy }) => {
|
||||||
|
assert_eq!((gx, gy), (dx, dy), "相对增量往返失真");
|
||||||
|
}
|
||||||
|
other => panic!("期望 Input::MouseMoveRel,实际 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判别序号守卫:`MouseMoveRel` 必须与既有变体**互不混淆**。
|
||||||
|
/// bincode 按位置编码枚举判别符,若新变体插在中间会让旧端静默错解——
|
||||||
|
/// 这条测试锁住「新增变体后各变体仍各自往返」。
|
||||||
|
#[test]
|
||||||
|
fn rel_move_does_not_alias_other_input_events() {
|
||||||
|
let rel = ControlMsg::Input(InputEvent::MouseMoveRel { dx: 7, dy: -9 });
|
||||||
|
let abs = ControlMsg::Input(InputEvent::MouseMove { x: 7, y: 9 });
|
||||||
|
let rb = encode_control(&rel);
|
||||||
|
let ab = encode_control(&abs);
|
||||||
|
assert_ne!(rb, ab, "相对与绝对移动的线格式不得相同");
|
||||||
|
match bincode::deserialize::<ControlMsg>(&rb[4..]).unwrap() {
|
||||||
|
ControlMsg::Input(InputEvent::MouseMoveRel { .. }) => {}
|
||||||
|
other => panic!("相对移动被错解为 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_event_roundtrips_utf16_units() {
|
||||||
|
// 「你好」+ 一个 emoji 的代理对(U+1F600 = D83D DE00)
|
||||||
|
let units: Vec<u16> = vec![0x4F60, 0x597D, 0xD83D, 0xDE00];
|
||||||
|
let m = ControlMsg::Input(InputEvent::Text {
|
||||||
|
units: units.clone(),
|
||||||
|
});
|
||||||
|
let bytes = encode_control(&m);
|
||||||
|
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
|
||||||
|
match back {
|
||||||
|
ControlMsg::Input(InputEvent::Text { units: got }) => assert_eq!(got, units),
|
||||||
|
other => panic!("期望 Input::Text,实际 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 落单代理项必须原样往返:IME 提交边界可能给出孤立高/低代理项,
|
||||||
|
/// 用 `String` 承载会在这里有损(这正是选 `Vec<u16>` 的理由)。
|
||||||
|
#[test]
|
||||||
|
fn text_event_keeps_lone_surrogate() {
|
||||||
|
let units = vec![0xD83Du16];
|
||||||
|
let m = ControlMsg::Input(InputEvent::Text { units: units.clone() });
|
||||||
|
let bytes = encode_control(&m);
|
||||||
|
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
|
||||||
|
match back {
|
||||||
|
ControlMsg::Input(InputEvent::Text { units: got }) => assert_eq!(got, units),
|
||||||
|
other => panic!("期望 Input::Text,实际 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_units_clamped_at_boundary() {
|
||||||
|
// 边界:恰好上限不截断;超一个才截断
|
||||||
|
let exact = vec![0x61u16; MAX_TEXT_UNITS];
|
||||||
|
let (kept, truncated) = clamp_text_units(exact);
|
||||||
|
assert_eq!(kept.len(), MAX_TEXT_UNITS);
|
||||||
|
assert!(!truncated);
|
||||||
|
|
||||||
|
let over = vec![0x61u16; MAX_TEXT_UNITS + 1];
|
||||||
|
let (kept, truncated) = clamp_text_units(over);
|
||||||
|
assert_eq!(kept.len(), MAX_TEXT_UNITS);
|
||||||
|
assert!(truncated, "超限必须返回截断标志,让调用方打印警告");
|
||||||
|
|
||||||
|
let (empty, truncated) = clamp_text_units(vec![]);
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
assert!(!truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空 `Text` 也必须能往返(IME 可能提交空串;调用方负责决定忽略还是报错)。
|
||||||
|
#[test]
|
||||||
|
fn text_event_empty_roundtrips() {
|
||||||
|
let m = ControlMsg::Input(InputEvent::Text { units: vec![] });
|
||||||
|
let bytes = encode_control(&m);
|
||||||
|
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
|
||||||
|
match back {
|
||||||
|
ControlMsg::Input(InputEvent::Text { units }) => assert!(units.is_empty()),
|
||||||
|
other => panic!("期望 Input::Text,实际 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上限内的 `Text` 必须能通过 `read_control` 的长度闸;
|
||||||
|
/// 反向验证:伪造一个超限的声明长度必须在分配前被拒。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_within_cap_passes_control_length_gate() {
|
||||||
|
let (mut a, mut b) = duplex(1 << 20);
|
||||||
|
let msg = ControlMsg::Input(InputEvent::Text {
|
||||||
|
units: vec![0x4F60; MAX_TEXT_UNITS],
|
||||||
|
});
|
||||||
|
write_control(&mut a, &msg).await.unwrap();
|
||||||
|
match read_control(&mut b).await.unwrap() {
|
||||||
|
ControlMsg::Input(InputEvent::Text { units }) => assert_eq!(units.len(), MAX_TEXT_UNITS),
|
||||||
|
other => panic!("期望 Input::Text,实际 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 视频帧头 ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_header_roundtrip_keeps_seq_and_keyframe() {
|
||||||
|
for (seq, kf) in [(0u64, true), (1, false), (u64::MAX, false)] {
|
||||||
|
let h = hdr(seq, kf);
|
||||||
|
let payload = b"\x00\x00\x00\x01\x65fake-nal";
|
||||||
|
let bytes = encode_video_frame(&h, payload);
|
||||||
|
|
||||||
|
let total = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
|
||||||
|
assert_eq!(total, bytes.len() - 4, "总长字段应不含自身");
|
||||||
|
|
||||||
|
let hlen = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
|
||||||
|
let back: VideoFrameHeader = bincode::deserialize(&bytes[8..8 + hlen]).unwrap();
|
||||||
|
assert_eq!(back.seq, seq);
|
||||||
|
assert_eq!(back.keyframe, kf);
|
||||||
|
assert_eq!(&bytes[8 + hlen..], payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn video_frame_write_read_roundtrip() {
|
||||||
|
let (mut a, mut b) = duplex(256 * 1024);
|
||||||
|
let h = hdr(7, true);
|
||||||
|
let payload = vec![0xABu8; 4096];
|
||||||
|
let bytes = encode_video_frame(&h, &payload);
|
||||||
|
tokio::io::AsyncWriteExt::write_all(&mut a, &bytes).await.unwrap();
|
||||||
|
|
||||||
|
let (h2, p2) = read_video_frame(&mut b).await.unwrap();
|
||||||
|
assert_eq!(h2.seq, 7);
|
||||||
|
assert!(h2.keyframe);
|
||||||
|
assert_eq!((h2.width, h2.height), (1920, 1080));
|
||||||
|
assert_eq!(p2, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 不可信输入(P3 的核心回归) ----
|
||||||
|
|
||||||
|
/// `total < 4` 必须报错而不是 panic:旧实现在这里 `buf[0..4]` 会越界 panic。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_total_below_minimum() {
|
||||||
|
for total in [0u32, 1, 3] {
|
||||||
|
let (mut a, mut b) = duplex(64);
|
||||||
|
tokio::io::AsyncWriteExt::write_all(&mut a, &total.to_le_bytes())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = read_video_frame(&mut b).await.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("out of range"),
|
||||||
|
"total={total} 应被拒绝,实际错误: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `total == 4` 但没有头数据:hlen 读出来是 0,必须拒绝。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_zero_header_length() {
|
||||||
|
let (mut a, mut b) = duplex(64);
|
||||||
|
let mut bytes = 4u32.to_le_bytes().to_vec();
|
||||||
|
bytes.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
tokio::io::AsyncWriteExt::write_all(&mut a, &bytes)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = read_video_frame(&mut b).await.unwrap_err().to_string();
|
||||||
|
assert!(err.contains("header length invalid"), "实际错误: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// hlen 声明超过 total-4:旧实现会切片越界 panic。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_header_length_beyond_frame() {
|
||||||
|
let (mut a, mut b) = duplex(64);
|
||||||
|
// total = 8 → body 必须正好 8 字节(hlen 字段 4 + 4 字节填充)
|
||||||
|
let mut bytes = 8u32.to_le_bytes().to_vec();
|
||||||
|
bytes.extend_from_slice(&999u32.to_le_bytes()); // hlen = 999 > total-4 = 4
|
||||||
|
bytes.extend_from_slice(&[0u8; 4]);
|
||||||
|
tokio::io::AsyncWriteExt::write_all(&mut a, &bytes)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = read_video_frame(&mut b).await.unwrap_err().to_string();
|
||||||
|
assert!(err.contains("header length invalid"), "实际错误: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 超过 256 MiB 上限必须在**分配前**拒绝,不能先 vec! 再检查。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_oversized_frame_without_allocating() {
|
||||||
|
let (mut a, mut b) = duplex(64);
|
||||||
|
let total = (MAX_VIDEO_FRAME as u32) + 1;
|
||||||
|
tokio::io::AsyncWriteExt::write_all(&mut a, &total.to_le_bytes())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = read_video_frame(&mut b).await.unwrap_err().to_string();
|
||||||
|
assert!(err.contains("out of range"), "实际错误: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 头部字节损坏(非合法 bincode)应返回 Err 而非 panic。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_corrupt_header_bytes() {
|
||||||
|
let (mut a, mut b) = duplex(64);
|
||||||
|
let garbage = [0xFFu8; 8];
|
||||||
|
let total = (4 + garbage.len()) as u32;
|
||||||
|
let mut bytes = total.to_le_bytes().to_vec();
|
||||||
|
bytes.extend_from_slice(&(garbage.len() as u32).to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&garbage);
|
||||||
|
tokio::io::AsyncWriteExt::write_all(&mut a, &bytes)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(read_video_frame(&mut b).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IDR 识别(P2 的 keyframe 标志来源) ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_idr_in_annex_b() {
|
||||||
|
// SPS(7) + PPS(8) + IDR 片(5),4 字节起始码
|
||||||
|
let idr = [
|
||||||
|
0, 0, 0, 1, 0x67, 0xAA, // SPS
|
||||||
|
0, 0, 0, 1, 0x68, 0xBB, // PPS
|
||||||
|
0, 0, 0, 1, 0x65, 0xCC, // IDR
|
||||||
|
];
|
||||||
|
assert!(h264_contains_idr(&idr));
|
||||||
|
|
||||||
|
// 3 字节起始码也应识别
|
||||||
|
let idr3 = [0, 0, 1, 0x65, 0xCC];
|
||||||
|
assert!(h264_contains_idr(&idr3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_idr_payloads() {
|
||||||
|
// 纯 P 片(NAL 类型 1)
|
||||||
|
let p_frame = [0, 0, 0, 1, 0x41, 0xDD];
|
||||||
|
assert!(!h264_contains_idr(&p_frame));
|
||||||
|
|
||||||
|
// 只有 SPS/PPS,没有片(关键帧边界可能落在下一个包,此时不应谎报 keyframe)
|
||||||
|
let params_only = [
|
||||||
|
0, 0, 0, 1, 0x67, 0xAA,
|
||||||
|
0, 0, 0, 1, 0x68, 0xBB,
|
||||||
|
];
|
||||||
|
assert!(!h264_contains_idr(¶ms_only));
|
||||||
|
|
||||||
|
assert!(!h264_contains_idr(&[]));
|
||||||
|
assert!(!h264_contains_idr(&[0, 0, 1])); // 起始码后无 NAL 字节
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
757
common/src/signaling.rs
Normal file
757
common/src/signaling.rs
Normal file
@ -0,0 +1,757 @@
|
|||||||
|
//! P2P 打洞信令协议(`common/` 契约,agent / viewer / 信令服务三端共享)。
|
||||||
|
//!
|
||||||
|
//! ## 职责边界(重要,改动前必读)
|
||||||
|
//! - **信令服务器只做两件事**:① 把双方的外部视角(公网映射地址 + NAT 行为)拼起来回推给对端;
|
||||||
|
//! ② 下发同步打洞计划,让双方在同一绝对时刻开始发包。建连完成后信令与本次会话再无关系,
|
||||||
|
//! 不承载任何媒体流量。
|
||||||
|
//! - **本协议不含中继**(无 relay / TURN 消息)。打洞失败就是失败,因此必须把原因讲清楚
|
||||||
|
//! (见 [`PunchFailReason::user_hint`]),不允许让用户停在"连接中"。
|
||||||
|
//! - 会话建立后走 QUIC:**不要**再用 viewer 现有的 `SkipVerify`,改用
|
||||||
|
//! [`EndpointInfo::cert_fingerprint`] 做公钥钉扎。
|
||||||
|
//!
|
||||||
|
//! ## 线格式
|
||||||
|
//! 类型只依赖 `serde`,不绑定具体编码。建议信令走 WSS/443(最容易穿过企业防火墙),
|
||||||
|
//! 编码用 JSON 便于抓包排查;本模块单元测试用 bincode 做往返校验,两者不冲突。
|
||||||
|
//!
|
||||||
|
//! ## 一次会话的时序
|
||||||
|
//! ```text
|
||||||
|
//! agent 信令服务器 viewer
|
||||||
|
//! |-- Register(info) ------->|<------- Register(info) -----|
|
||||||
|
//! |<-- Registered(observed) -|------ Registered(observed) ->|
|
||||||
|
//! |<-- PeerReady(peer info) -|------ PeerReady(peer info) -->|
|
||||||
|
//! |<-- PunchPlan(start_in) --|------ PunchPlan(start_in) --->|
|
||||||
|
//! | (双方同时向对端候选列表发包,多轮) |
|
||||||
|
//! |<========= 打通:UDP 直连 → QUIC 握手 + 证书钉扎 =========>|
|
||||||
|
//! |--- PunchReport(Connected) ->|<---- PunchReport ----------|
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 常量
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 信令协议版本。双方不一致时服务器应拒绝并提示升级,不要尝试兼容旧版。
|
||||||
|
pub const SIGNALING_VERSION: u16 = 1;
|
||||||
|
|
||||||
|
/// 打洞用的 UDP 端口,与 agent 当前监听端口一致。
|
||||||
|
/// 固定端口对两件事有直接好处:NAT 端口预测、以及用户手动做端口转发。
|
||||||
|
pub const PUNCH_UDP_PORT: u16 = 47800;
|
||||||
|
|
||||||
|
/// 信令默认端口。走 WSS/443 是因为企业防火墙通常只放行 80/443。
|
||||||
|
pub const SIGNALING_DEFAULT_PORT: u16 = 443;
|
||||||
|
|
||||||
|
/// 单次打洞的默认轮数。
|
||||||
|
pub const PUNCH_DEFAULT_ROUNDS: u8 = 3;
|
||||||
|
|
||||||
|
/// 打洞轮间隔(毫秒)。太短来不及让 NAT 建立映射,太长用户等不及。
|
||||||
|
pub const PUNCH_ROUND_INTERVAL_MS: u16 = 200;
|
||||||
|
|
||||||
|
/// 候选地址数量上限(含预测端口)。超出即截断,防止滥用与报文膨胀。
|
||||||
|
pub const MAX_CANDIDATES: usize = 32;
|
||||||
|
|
||||||
|
/// NAT 绑定保活周期的下限/上限(秒)。
|
||||||
|
pub const KEEPALIVE_MIN_S: u16 = 5;
|
||||||
|
pub const KEEPALIVE_MAX_S: u16 = 60;
|
||||||
|
|
||||||
|
/// 未测出 NAT 绑定超时时的保活默认值(秒)。家用 NAT 的 UDP 映射普遍 30s 左右超时。
|
||||||
|
pub const KEEPALIVE_DEFAULT_S: u16 = 15;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 基础类型
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 端点角色。打洞成功后 agent 作为 QUIC server,viewer 主动 connect。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Role {
|
||||||
|
/// 被控端
|
||||||
|
Agent,
|
||||||
|
/// 主控端
|
||||||
|
Viewer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Role {
|
||||||
|
pub fn peer(self) -> Role {
|
||||||
|
match self {
|
||||||
|
Role::Agent => Role::Viewer,
|
||||||
|
Role::Viewer => Role::Agent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 候选地址的来源,决定打洞优先级。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum CandidateKind {
|
||||||
|
/// 本机网卡地址(同局域网直连用,最省事)
|
||||||
|
Host,
|
||||||
|
/// 经 UPnP / NAT-PMP / PCP 主动申请的端口映射(把对称 NAT 变成可直连)
|
||||||
|
Upnp,
|
||||||
|
/// 信令服务器观察到的公网映射(等价于一次 STUN binding,server-reflexive)
|
||||||
|
Reflexive,
|
||||||
|
/// 按 NAT 端口分配规律预测出的端口
|
||||||
|
Predicted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个候选地址。IPv6 有公网地址时基本等同直连,因此固定排在 IPv4 之前。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Candidate {
|
||||||
|
pub addr: SocketAddr,
|
||||||
|
pub kind: CandidateKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Candidate {
|
||||||
|
pub fn new(addr: SocketAddr, kind: CandidateKind) -> Self {
|
||||||
|
Self { addr, kind }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_ipv6(&self) -> bool {
|
||||||
|
self.addr.is_ipv6()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打分越高越先打。IPv6(+100)> UPnP 显式映射 > 本机地址 > 公网反射 > 端口预测。
|
||||||
|
pub fn score(&self) -> u16 {
|
||||||
|
let base = match self.kind {
|
||||||
|
CandidateKind::Upnp => 90,
|
||||||
|
CandidateKind::Host => 80,
|
||||||
|
CandidateKind::Reflexive => 60,
|
||||||
|
CandidateKind::Predicted => 40,
|
||||||
|
};
|
||||||
|
if self.is_ipv6() {
|
||||||
|
base + 100
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按优先级从高到低排序(`sort_by` 是稳定排序,同分保持调用方给出的顺序)。
|
||||||
|
pub fn sort_candidates(v: &mut [Candidate]) {
|
||||||
|
v.sort_by(|a, b| b.score().cmp(&a.score()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清洗对端送来的候选列表:丢弃端口 0、按地址去重、截断到 [`MAX_CANDIDATES`]。
|
||||||
|
///
|
||||||
|
/// 对端数据一律视为不可信输入;打洞阶段每个候选都会产生实际发包,必须设上限。
|
||||||
|
pub fn sanitize_candidates(v: Vec<Candidate>) -> Vec<Candidate> {
|
||||||
|
let mut out: Vec<Candidate> = Vec::with_capacity(v.len().min(MAX_CANDIDATES));
|
||||||
|
for c in v {
|
||||||
|
if c.addr.port() == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if out.iter().any(|e| e.addr == c.addr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if out.len() == MAX_CANDIDATES {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NAT 画像与诊断
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// RFC 4787 §4.1 映射行为:同一个内网 socket 的公网端口是否随目标变化。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum MappingBehavior {
|
||||||
|
/// 端点无关(full cone / restricted cone):打洞最理想
|
||||||
|
EndpointIndependent,
|
||||||
|
/// 地址相关
|
||||||
|
AddressDependent,
|
||||||
|
/// 地址+端口相关(即"对称 NAT")
|
||||||
|
AddressAndPortDependent,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 4787 §5.1 过滤行为:外部包能否进得来。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum FilteringBehavior {
|
||||||
|
EndpointIndependent,
|
||||||
|
AddressDependent,
|
||||||
|
AddressAndPortDependent,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 公网端口分配规律,决定端口预测是否可用。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PortAllocation {
|
||||||
|
/// 保持内网端口
|
||||||
|
Preserved,
|
||||||
|
/// 递增分配(可预测,端口预测值得一试)
|
||||||
|
Sequential,
|
||||||
|
/// 随机(端口预测无效)
|
||||||
|
Random,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NAT 行为画像。信令握手阶段顺带测出,用于:① 决定打洞策略;② 失败时给用户解释。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NatProfile {
|
||||||
|
pub mapping: MappingBehavior,
|
||||||
|
pub filtering: FilteringBehavior,
|
||||||
|
pub allocation: PortAllocation,
|
||||||
|
/// NAT 绑定超时(秒)。未测出为 `None`,此时用 [`KEEPALIVE_DEFAULT_S`]。
|
||||||
|
pub binding_timeout_s: Option<u16>,
|
||||||
|
/// 是否支持 hairpinning(同一 NAT 内的两端可互连)
|
||||||
|
pub hairpinning: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NatProfile {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mapping: MappingBehavior::Unknown,
|
||||||
|
filtering: FilteringBehavior::Unknown,
|
||||||
|
allocation: PortAllocation::Unknown,
|
||||||
|
binding_timeout_s: None,
|
||||||
|
hairpinning: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打洞前景评估,直接用于 UI 提示与"是否值得继续重试"的决策。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PunchOutlook {
|
||||||
|
/// 锥形 NAT 或公网 IPv6,基本必成
|
||||||
|
Likely,
|
||||||
|
/// 需要多轮重试 / 端口预测,可能成
|
||||||
|
Possible,
|
||||||
|
/// 对称 NAT 且端口不可预测,通常打不通
|
||||||
|
Unlikely,
|
||||||
|
/// 运营商级 NAT(CGNAT),无中继方案下无解 —— 不必浪费多轮打洞
|
||||||
|
Hopeless,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 运营商级 NAT 判定:RFC 6598 共享地址空间 `100.64.0.0/10`。
|
||||||
|
///
|
||||||
|
/// 命中即无中继不可达,应立刻给出"换网络"提示,而不是让用户等完所有打洞轮次。
|
||||||
|
pub fn is_cgnat(observed: IpAddr) -> bool {
|
||||||
|
match observed {
|
||||||
|
IpAddr::V4(v4) => {
|
||||||
|
let o = v4.octets();
|
||||||
|
o[0] == 100 && (64..=127).contains(&o[1])
|
||||||
|
}
|
||||||
|
IpAddr::V6(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 综合 NAT 画像与观察到的公网地址,评估打洞前景。
|
||||||
|
///
|
||||||
|
/// `observed_public` 是信令服务器看到的本端公网映射(IPv6 时即本机全局地址)。
|
||||||
|
pub fn punch_outlook(nat: &NatProfile, observed_public: IpAddr) -> PunchOutlook {
|
||||||
|
// IPv6 没有 NAT 可言,只要双方都有公网 v6 就是真直连。
|
||||||
|
if observed_public.is_ipv6() {
|
||||||
|
return PunchOutlook::Likely;
|
||||||
|
}
|
||||||
|
if is_cgnat(observed_public) {
|
||||||
|
return PunchOutlook::Hopeless;
|
||||||
|
}
|
||||||
|
match nat.mapping {
|
||||||
|
MappingBehavior::EndpointIndependent => PunchOutlook::Likely,
|
||||||
|
MappingBehavior::AddressDependent => PunchOutlook::Possible,
|
||||||
|
MappingBehavior::AddressAndPortDependent => match nat.allocation {
|
||||||
|
// 对称 NAT 但端口可预测 → 端口预测 + 多轮重试仍有机会
|
||||||
|
PortAllocation::Sequential | PortAllocation::Preserved => PunchOutlook::Possible,
|
||||||
|
PortAllocation::Random | PortAllocation::Unknown => PunchOutlook::Unlikely,
|
||||||
|
},
|
||||||
|
// 测不出来就按"可能"处理,先试再说
|
||||||
|
MappingBehavior::Unknown => PunchOutlook::Possible,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 双方协商保活周期(秒):取双方绑定超时的较小值再折半,并夹在
|
||||||
|
/// [`KEEPALIVE_MIN_S`]..=[`KEEPALIVE_MAX_S`] 之间。
|
||||||
|
///
|
||||||
|
/// 洞是靠出站包维持的,超时后会静默关闭 —— 表现为"连上了一会儿就断"。
|
||||||
|
pub fn keepalive_secs(mine: &NatProfile, theirs: &NatProfile) -> u16 {
|
||||||
|
let t = match (mine.binding_timeout_s, theirs.binding_timeout_s) {
|
||||||
|
(Some(a), Some(b)) => a.min(b),
|
||||||
|
(Some(a), None) => a,
|
||||||
|
(None, Some(b)) => b,
|
||||||
|
(None, None) => KEEPALIVE_DEFAULT_S,
|
||||||
|
};
|
||||||
|
(t / 2).clamp(KEEPALIVE_MIN_S, KEEPALIVE_MAX_S)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 服务器侧计算某个客户端的打洞启动延迟(相对 PunchPlan 送达时刻)。
|
||||||
|
///
|
||||||
|
/// 两侧 RTT 不同:若下发同一个延迟,RTT 大的一方会晚发包,洞就打不上。
|
||||||
|
/// 以双方最大 RTT 为基准、减去自身 RTT 的一半,即可在**不做时钟同步**的前提下
|
||||||
|
/// 让两侧的绝对开打时刻对齐(误差约等于单程抖动)。
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// start_in_ms = max_rtt_ms / 2 + margin_ms - own_rtt_ms / 2
|
||||||
|
/// ```
|
||||||
|
pub fn punch_start_delay_ms(own_rtt_ms: u16, max_rtt_ms: u16, margin_ms: u16) -> u16 {
|
||||||
|
let base = max_rtt_ms as u32 / 2 + margin_ms as u32;
|
||||||
|
let own_half = own_rtt_ms as u32 / 2;
|
||||||
|
base.saturating_sub(own_half).min(u16::MAX as u32) as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 证书指纹(替代 viewer 现有的 SkipVerify)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 自签证书公钥指纹(SHA-256 的 32 字节裸值)。
|
||||||
|
///
|
||||||
|
/// agent 生成自签证书后把指纹经信令发布,viewer 用它对 QUIC 连接做**公钥钉扎**,
|
||||||
|
/// 从而去掉 `viewer/src/main.rs` 里的 `SkipVerify`(公网环境不可接受)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CertFingerprint(pub [u8; 32]);
|
||||||
|
|
||||||
|
impl CertFingerprint {
|
||||||
|
/// 小写十六进制(64 字符)
|
||||||
|
pub fn to_hex(&self) -> String {
|
||||||
|
self.0.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 64 字符十六进制;长度不符或含非十六进制字符返回 `None`。
|
||||||
|
pub fn from_hex(s: &str) -> Option<Self> {
|
||||||
|
let s = s.trim();
|
||||||
|
if s.len() != 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
|
||||||
|
let hi = (chunk[0] as char).to_digit(16)?;
|
||||||
|
let lo = (chunk[1] as char).to_digit(16)?;
|
||||||
|
out[i] = (hi * 16 + lo) as u8;
|
||||||
|
}
|
||||||
|
Some(Self(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CertFingerprint {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.to_hex())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 端点信息与消息
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 连通性相关的能力标志。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Capabilities {
|
||||||
|
/// 已成功获得 UPnP / NAT-PMP / PCP 端口映射
|
||||||
|
pub upnp_mapped: bool,
|
||||||
|
/// 本机具备可用 IPv6 出口
|
||||||
|
pub ipv6_available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个端点的完整连通性描述,随 Register / PeerReady 交换。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EndpointInfo {
|
||||||
|
pub role: Role,
|
||||||
|
/// 本端打洞用的 UDP 监听端口(通常等于 [`PUNCH_UDP_PORT`])
|
||||||
|
pub port: u16,
|
||||||
|
/// 候选地址,发送前请用 [`sort_candidates`] 排好序
|
||||||
|
pub candidates: Vec<Candidate>,
|
||||||
|
pub nat: NatProfile,
|
||||||
|
/// QUIC 证书公钥指纹,供对端钉扎
|
||||||
|
pub cert_fingerprint: CertFingerprint,
|
||||||
|
pub caps: Capabilities,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打洞失败原因。**无中继方案的用户体验下限就在这里** —— 每种原因都要能翻译成
|
||||||
|
/// 用户能照做的一句话,见 [`PunchFailReason::user_hint`]。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PunchFailReason {
|
||||||
|
/// 所有候选都无回包(最常见:双方都是地址+端口相关型 NAT)
|
||||||
|
NoCandidateReachable,
|
||||||
|
/// 双方均为对称 NAT 且端口不可预测
|
||||||
|
SymmetricNat,
|
||||||
|
/// 运营商级 NAT(100.64/10)
|
||||||
|
Cgnat,
|
||||||
|
/// 本机 UDP 出站被策略封锁(企业防火墙常见)
|
||||||
|
UdpBlocked,
|
||||||
|
/// 对端提前退出
|
||||||
|
PeerAborted,
|
||||||
|
/// 超时未收到任何回包
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PunchFailReason {
|
||||||
|
/// 面向用户的中文处置建议。不要吞掉这个信息 —— 用户看不到原因就只会认为"这软件连不上"。
|
||||||
|
pub fn user_hint(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PunchFailReason::NoCandidateReachable => {
|
||||||
|
"双方网络都无法直接互通。请在被控端路由器开启 UPnP 后重试,或双方改用 IPv6 网络。"
|
||||||
|
}
|
||||||
|
PunchFailReason::SymmetricNat => {
|
||||||
|
"当前网络是对称型 NAT,无法直连。请在被控端路由器开启 UPnP,或更换网络后重试。"
|
||||||
|
}
|
||||||
|
PunchFailReason::Cgnat => {
|
||||||
|
"当前处于运营商级 NAT(大内网),无法直连。请改用 IPv6 网络,或换一条能拿到公网地址的线路。"
|
||||||
|
}
|
||||||
|
PunchFailReason::UdpBlocked => {
|
||||||
|
"当前网络的防火墙拦截了 UDP 出站。请更换网络(如切换手机热点)后重试。"
|
||||||
|
}
|
||||||
|
PunchFailReason::PeerAborted => "对端已取消本次连接。",
|
||||||
|
PunchFailReason::Timeout => "连接超时,可能是网络波动。请重试一次。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打洞结果。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PunchOutcome {
|
||||||
|
/// 打通:`peer` 为真正生效的对端地址,`rtt_ms` 为实测往返
|
||||||
|
Connected { peer: SocketAddr, rtt_ms: u16 },
|
||||||
|
Failed { reason: PunchFailReason },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PunchOutcome {
|
||||||
|
pub fn is_connected(&self) -> bool {
|
||||||
|
matches!(self, PunchOutcome::Connected { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已连通返回 `None`,失败返回给用户看的建议。
|
||||||
|
pub fn user_hint(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
PunchOutcome::Connected { .. } => None,
|
||||||
|
PunchOutcome::Failed { reason } => Some(reason.user_hint()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 信令层错误码。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum SignalError {
|
||||||
|
/// [`SIGNALING_VERSION`] 不匹配(客户端需升级)
|
||||||
|
VersionMismatch,
|
||||||
|
/// 验证码错误或已过期
|
||||||
|
BadAuth,
|
||||||
|
SessionNotFound,
|
||||||
|
PeerOffline,
|
||||||
|
/// 候选地址数量超限
|
||||||
|
TooManyCandidates,
|
||||||
|
RateLimited,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户端 → 信令服务器。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ClientMsg {
|
||||||
|
/// 注册并等待对端。`auth` 必须是一次性验证码(不是 MVP 里的固定 123456)。
|
||||||
|
Register {
|
||||||
|
version: u16,
|
||||||
|
/// 会话/设备标识;服务器据此配对两端
|
||||||
|
session: String,
|
||||||
|
auth: String,
|
||||||
|
info: EndpointInfo,
|
||||||
|
},
|
||||||
|
/// 候选变化补报:UPnP 映射稍后才成功、或网卡地址变化时用。
|
||||||
|
UpdateCandidates {
|
||||||
|
session: String,
|
||||||
|
candidates: Vec<Candidate>,
|
||||||
|
nat: NatProfile,
|
||||||
|
},
|
||||||
|
/// 打洞结果回报。服务器可据此记录有效路径、统计成功率。
|
||||||
|
PunchReport {
|
||||||
|
session: String,
|
||||||
|
plan_id: u32,
|
||||||
|
outcome: PunchOutcome,
|
||||||
|
},
|
||||||
|
/// 保活:维持信令长连接,同时刷新本端 NAT 绑定。
|
||||||
|
Keepalive { session: String },
|
||||||
|
Bye {
|
||||||
|
session: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 信令服务器 → 客户端。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ServerMsg {
|
||||||
|
/// 注册成功。`observed` 是服务器看到的本端公网映射,等价于一次 STUN binding;
|
||||||
|
/// `rtt_ms` 供打洞计划做时间对齐(见 [`punch_start_delay_ms`])。
|
||||||
|
Registered {
|
||||||
|
session: String,
|
||||||
|
observed: SocketAddr,
|
||||||
|
/// 对端是否已在场。`false` 时应等待 [`ServerMsg::PeerReady`]。
|
||||||
|
peer_present: bool,
|
||||||
|
rtt_ms: u16,
|
||||||
|
},
|
||||||
|
/// 对端已就绪,附带其候选地址、NAT 画像与证书指纹。
|
||||||
|
PeerReady {
|
||||||
|
peer: EndpointInfo,
|
||||||
|
peer_observed: SocketAddr,
|
||||||
|
/// 打洞前景,可直接显示给用户("很可能直连成功" / "当前网络可能无法直连")
|
||||||
|
outlook: PunchOutlook,
|
||||||
|
},
|
||||||
|
/// 同步打洞计划。
|
||||||
|
///
|
||||||
|
/// `start_in_ms` 是**相对本消息**的延迟,服务器对两侧下发不同的值以使双方在同一
|
||||||
|
/// 绝对时刻开打 —— 因此本协议不要求两端时钟同步。
|
||||||
|
PunchPlan {
|
||||||
|
plan_id: u32,
|
||||||
|
start_in_ms: u16,
|
||||||
|
rounds: u8,
|
||||||
|
round_interval_ms: u16,
|
||||||
|
/// 对端候选(已排序、已清洗);本端需对其中每个地址发包
|
||||||
|
peer_candidates: Vec<Candidate>,
|
||||||
|
},
|
||||||
|
/// 打洞结果确认(用于 UI 显示"直连成功,RTT xx ms")。
|
||||||
|
PunchAck { plan_id: u32, outcome: PunchOutcome },
|
||||||
|
Error { code: SignalError, message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 测试
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fp(byte: u8) -> CertFingerprint {
|
||||||
|
CertFingerprint([byte; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(role: Role) -> EndpointInfo {
|
||||||
|
let mut candidates = vec![
|
||||||
|
Candidate::new("1.2.3.4:47800".parse().unwrap(), CandidateKind::Reflexive),
|
||||||
|
Candidate::new("[2001:db8::1]:47800".parse().unwrap(), CandidateKind::Host),
|
||||||
|
Candidate::new("192.168.1.5:47800".parse().unwrap(), CandidateKind::Upnp),
|
||||||
|
];
|
||||||
|
sort_candidates(&mut candidates);
|
||||||
|
EndpointInfo {
|
||||||
|
role,
|
||||||
|
port: PUNCH_UDP_PORT,
|
||||||
|
candidates,
|
||||||
|
nat: NatProfile {
|
||||||
|
mapping: MappingBehavior::AddressDependent,
|
||||||
|
filtering: FilteringBehavior::AddressAndPortDependent,
|
||||||
|
allocation: PortAllocation::Sequential,
|
||||||
|
binding_timeout_s: Some(30),
|
||||||
|
hairpinning: false,
|
||||||
|
},
|
||||||
|
cert_fingerprint: fp(0xab),
|
||||||
|
caps: Capabilities {
|
||||||
|
upnp_mapped: true,
|
||||||
|
ipv6_available: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_msgs() -> Vec<ClientMsg> {
|
||||||
|
vec![
|
||||||
|
ClientMsg::Register {
|
||||||
|
version: SIGNALING_VERSION,
|
||||||
|
session: "dev-1".into(),
|
||||||
|
auth: "0f3a91".into(),
|
||||||
|
info: info(Role::Agent),
|
||||||
|
},
|
||||||
|
ClientMsg::UpdateCandidates {
|
||||||
|
session: "dev-1".into(),
|
||||||
|
candidates: vec![Candidate::new(
|
||||||
|
"5.6.7.8:47800".parse().unwrap(),
|
||||||
|
CandidateKind::Predicted,
|
||||||
|
)],
|
||||||
|
nat: NatProfile::default(),
|
||||||
|
},
|
||||||
|
ClientMsg::PunchReport {
|
||||||
|
session: "dev-1".into(),
|
||||||
|
plan_id: 7,
|
||||||
|
outcome: PunchOutcome::Connected {
|
||||||
|
peer: "9.9.9.9:47800".parse().unwrap(),
|
||||||
|
rtt_ms: 42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ClientMsg::PunchReport {
|
||||||
|
session: "dev-1".into(),
|
||||||
|
plan_id: 8,
|
||||||
|
outcome: PunchOutcome::Failed {
|
||||||
|
reason: PunchFailReason::Cgnat,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ClientMsg::Keepalive {
|
||||||
|
session: "dev-1".into(),
|
||||||
|
},
|
||||||
|
ClientMsg::Bye {
|
||||||
|
session: "dev-1".into(),
|
||||||
|
reason: "user quit".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_msgs() -> Vec<ServerMsg> {
|
||||||
|
vec![
|
||||||
|
ServerMsg::Registered {
|
||||||
|
session: "dev-1".into(),
|
||||||
|
observed: "203.0.113.9:51234".parse().unwrap(),
|
||||||
|
peer_present: true,
|
||||||
|
rtt_ms: 38,
|
||||||
|
},
|
||||||
|
ServerMsg::PeerReady {
|
||||||
|
peer: info(Role::Viewer),
|
||||||
|
peer_observed: "203.0.113.10:51235".parse().unwrap(),
|
||||||
|
outlook: PunchOutlook::Likely,
|
||||||
|
},
|
||||||
|
ServerMsg::PunchPlan {
|
||||||
|
plan_id: 9,
|
||||||
|
start_in_ms: 130,
|
||||||
|
rounds: PUNCH_DEFAULT_ROUNDS,
|
||||||
|
round_interval_ms: PUNCH_ROUND_INTERVAL_MS,
|
||||||
|
peer_candidates: info(Role::Viewer).candidates,
|
||||||
|
},
|
||||||
|
ServerMsg::PunchAck {
|
||||||
|
plan_id: 9,
|
||||||
|
outcome: PunchOutcome::Failed {
|
||||||
|
reason: PunchFailReason::SymmetricNat,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ServerMsg::Error {
|
||||||
|
code: SignalError::VersionMismatch,
|
||||||
|
message: "server expects v1".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bincode_roundtrip_client_msgs() {
|
||||||
|
for m in client_msgs() {
|
||||||
|
let bytes = bincode::serialize(&m).expect("serialize");
|
||||||
|
let back: ClientMsg = bincode::deserialize(&bytes).expect("deserialize");
|
||||||
|
assert_eq!(m, back);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bincode_roundtrip_server_msgs() {
|
||||||
|
for m in server_msgs() {
|
||||||
|
let bytes = bincode::serialize(&m).expect("serialize");
|
||||||
|
let back: ServerMsg = bincode::deserialize(&bytes).expect("deserialize");
|
||||||
|
assert_eq!(m, back);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn candidates_sorted_ipv6_first_then_upnp() {
|
||||||
|
let mut v = vec![
|
||||||
|
Candidate::new("1.2.3.4:47800".parse().unwrap(), CandidateKind::Reflexive),
|
||||||
|
Candidate::new("[2001:db8::1]:47800".parse().unwrap(), CandidateKind::Host),
|
||||||
|
Candidate::new("192.168.1.5:47800".parse().unwrap(), CandidateKind::Upnp),
|
||||||
|
];
|
||||||
|
sort_candidates(&mut v);
|
||||||
|
assert!(v[0].is_ipv6(), "IPv6 候选必须排第一");
|
||||||
|
assert_eq!(v[1].kind, CandidateKind::Upnp);
|
||||||
|
assert_eq!(v[2].kind, CandidateKind::Reflexive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_dedups_and_bounds() {
|
||||||
|
let dup = Candidate::new("1.2.3.4:47800".parse().unwrap(), CandidateKind::Host);
|
||||||
|
let zero_port = Candidate::new("1.2.3.4:0".parse().unwrap(), CandidateKind::Host);
|
||||||
|
let mut v = vec![dup, dup, zero_port];
|
||||||
|
v.extend((0..MAX_CANDIDATES + 5).map(|i| {
|
||||||
|
Candidate::new(
|
||||||
|
format!("10.0.{}.{}:47800", i / 250, i % 250).parse().unwrap(),
|
||||||
|
CandidateKind::Predicted,
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
let out = sanitize_candidates(v);
|
||||||
|
assert_eq!(out.len(), MAX_CANDIDATES);
|
||||||
|
assert!(out.iter().all(|c| c.addr.port() != 0));
|
||||||
|
let mut addrs: Vec<_> = out.iter().map(|c| c.addr).collect();
|
||||||
|
addrs.sort();
|
||||||
|
addrs.dedup();
|
||||||
|
assert_eq!(addrs.len(), out.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outlook_covers_ipv6_cgnat_and_symmetric() {
|
||||||
|
let v6: IpAddr = "2001:db8::1".parse().unwrap();
|
||||||
|
let public: IpAddr = "203.0.113.9".parse().unwrap();
|
||||||
|
let cgnat: IpAddr = "100.64.1.1".parse().unwrap();
|
||||||
|
|
||||||
|
let mut nat = NatProfile::default();
|
||||||
|
assert_eq!(punch_outlook(&nat, v6), PunchOutlook::Likely);
|
||||||
|
assert_eq!(punch_outlook(&nat, cgnat), PunchOutlook::Hopeless);
|
||||||
|
assert!(is_cgnat("100.127.255.254".parse().unwrap()));
|
||||||
|
assert!(!is_cgnat("100.128.0.1".parse().unwrap()));
|
||||||
|
|
||||||
|
nat.mapping = MappingBehavior::EndpointIndependent;
|
||||||
|
assert_eq!(punch_outlook(&nat, public), PunchOutlook::Likely);
|
||||||
|
|
||||||
|
nat.mapping = MappingBehavior::AddressAndPortDependent;
|
||||||
|
nat.allocation = PortAllocation::Sequential;
|
||||||
|
assert_eq!(punch_outlook(&nat, public), PunchOutlook::Possible);
|
||||||
|
|
||||||
|
nat.allocation = PortAllocation::Random;
|
||||||
|
assert_eq!(punch_outlook(&nat, public), PunchOutlook::Unlikely);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keepalive_halves_binding_timeout_and_clamps() {
|
||||||
|
let a = NatProfile {
|
||||||
|
binding_timeout_s: Some(30),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let b = NatProfile {
|
||||||
|
binding_timeout_s: Some(20),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(keepalive_secs(&a, &b), 10);
|
||||||
|
assert_eq!(keepalive_secs(&NatProfile::default(), &NatProfile::default()), 7);
|
||||||
|
let short = NatProfile {
|
||||||
|
binding_timeout_s: Some(4),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(keepalive_secs(&short, &short), KEEPALIVE_MIN_S);
|
||||||
|
let long = NatProfile {
|
||||||
|
binding_timeout_s: Some(600),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(keepalive_secs(&long, &long), KEEPALIVE_MAX_S);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn punch_start_delay_aligns_absolute_time() {
|
||||||
|
// RTT 大的一方应该更早出发,使两侧绝对开打时刻一致。
|
||||||
|
let slow = punch_start_delay_ms(200, 200, 100);
|
||||||
|
let fast = punch_start_delay_ms(20, 200, 100);
|
||||||
|
assert_eq!(slow, 100);
|
||||||
|
assert_eq!(fast, 190);
|
||||||
|
assert!(fast > slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_fingerprint_hex_roundtrip() {
|
||||||
|
let f = fp(0x0f);
|
||||||
|
let hex = f.to_hex();
|
||||||
|
assert_eq!(hex.len(), 64);
|
||||||
|
assert_eq!(CertFingerprint::from_hex(&hex), Some(f));
|
||||||
|
assert_eq!(CertFingerprint::from_hex(&hex.to_uppercase()), Some(f));
|
||||||
|
assert_eq!(CertFingerprint::from_hex("00"), None);
|
||||||
|
assert_eq!(CertFingerprint::from_hex(&"z".repeat(64)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fail_reasons_all_have_user_hints() {
|
||||||
|
let all = [
|
||||||
|
PunchFailReason::NoCandidateReachable,
|
||||||
|
PunchFailReason::SymmetricNat,
|
||||||
|
PunchFailReason::Cgnat,
|
||||||
|
PunchFailReason::UdpBlocked,
|
||||||
|
PunchFailReason::PeerAborted,
|
||||||
|
PunchFailReason::Timeout,
|
||||||
|
];
|
||||||
|
for r in all {
|
||||||
|
assert!(!r.user_hint().is_empty());
|
||||||
|
let o = PunchOutcome::Failed { reason: r };
|
||||||
|
assert!(!o.is_connected());
|
||||||
|
assert!(o.user_hint().is_some());
|
||||||
|
}
|
||||||
|
let ok = PunchOutcome::Connected {
|
||||||
|
peer: "1.2.3.4:47800".parse().unwrap(),
|
||||||
|
rtt_ms: 30,
|
||||||
|
};
|
||||||
|
assert!(ok.is_connected());
|
||||||
|
assert!(ok.user_hint().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -242,8 +242,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn coordinate_scaling_covers_full_range() {
|
fn coordinate_scaling_covers_full_range() {
|
||||||
assert_eq!(scale(0, 1920), 0);
|
assert_eq!(scale(0, 1920), 0);
|
||||||
|
// 归一化上界必须映射到 extent-1,否则指针永远到不了屏幕最右/最下
|
||||||
assert_eq!(scale(65535, 1920), 1919);
|
assert_eq!(scale(65535, 1920), 1919);
|
||||||
assert_eq!(scale(32768, 1920), 960);
|
// 中点:32768 × 1919 ÷ 65535 = 959.49…,整数除法向下取整 → 959
|
||||||
|
assert_eq!(scale(32768, 1920), 959);
|
||||||
assert_eq!(scale(12345, 0), 0); // 退化尺寸不 panic
|
assert_eq!(scale(12345, 0), 0); // 退化尺寸不 panic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -289,11 +289,17 @@ async fn end_to_end_capture_encode_transport_input() -> Result<()> {
|
|||||||
&mut send,
|
&mut send,
|
||||||
&ControlMsg::Auth {
|
&ControlMsg::Auth {
|
||||||
code: "smoke-code".into(),
|
code: "smoke-code".into(),
|
||||||
|
proto_version: common::PROTO_VERSION,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
match read_control(&mut recv).await? {
|
match read_control(&mut recv).await? {
|
||||||
ControlMsg::AuthOk { width, height } => {
|
ControlMsg::AuthOk {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
proto_version,
|
||||||
|
} => {
|
||||||
|
assert_eq!(proto_version, common::PROTO_VERSION, "被控端协议版本不符");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(width, height),
|
(width, height),
|
||||||
(sw, sh),
|
(sw, sh),
|
||||||
@ -429,6 +435,7 @@ async fn rejects_wrong_verification_code() -> Result<()> {
|
|||||||
&mut send,
|
&mut send,
|
||||||
&ControlMsg::Auth {
|
&ControlMsg::Auth {
|
||||||
code: "wrong-code".into(),
|
code: "wrong-code".into(),
|
||||||
|
proto_version: common::PROTO_VERSION,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@ -43,6 +43,9 @@ if [ "$ready" -ne 1 ]; then
|
|||||||
fi
|
fi
|
||||||
echo "[smoke] X server 就绪:$(xdpyinfo -display "$DISPLAY_NUM" | grep -E 'dimensions' | head -1)"
|
echo "[smoke] X server 就绪:$(xdpyinfo -display "$DISPLAY_NUM" | grep -E 'dimensions' | head -1)"
|
||||||
|
|
||||||
# 两个测试共用同一块虚拟屏与指针,必须串行
|
# 先跑纯逻辑单测(键码映射 / 坐标换算),不需要 X server
|
||||||
|
cargo test -p linux-agent --bins
|
||||||
|
|
||||||
|
# 再跑端到端用例:两个用例共用同一块虚拟屏与指针,必须串行
|
||||||
cargo test -p linux-agent --test smoke -- --nocapture --test-threads=1
|
cargo test -p linux-agent --test smoke -- --nocapture --test-threads=1
|
||||||
echo "[smoke] 通过"
|
echo "[smoke] 通过"
|
||||||
|
|||||||
147
tools/capture_evidence.ps1
Normal file
147
tools/capture_evidence.ps1
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# 2b evidence capture: byte-safe device screenshots of each acceptance state.
|
||||||
|
# ASCII-only source. Screenshots go through tools\shot.ps1 because PS 5.1 `>`
|
||||||
|
# writes UTF-16 and corrupts PNG bytes (see that file's header).
|
||||||
|
# Text/state assertions use uiautomator (lessons R-6: vivo logcat is throttled).
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = (Resolve-Path "$PSScriptRoot\..").Path
|
||||||
|
$ev = "D:\working\projects\yuankong\evidence\2b"
|
||||||
|
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||||
|
# PC (controlled side) LAN address; DHCP-assigned. 2026-10-31: the stale
|
||||||
|
# .180 default silently cost proof runs after the office network moved to
|
||||||
|
# .110 - pass -PhoneIp (param) or update this line when it changes again.
|
||||||
|
# The 192.168.1.199 in step 2 is INTENTIONALLY unreachable (V5 error path).
|
||||||
|
$phoneIp = "192.168.1.110"
|
||||||
|
|
||||||
|
function Adb-Shell {
|
||||||
|
param([Parameter(Mandatory)][string[]]$Cmd)
|
||||||
|
$old = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
$out = & $adb shell @Cmd 2>$null
|
||||||
|
return @{ out = ($out -join "`n"); code = $LASTEXITCODE }
|
||||||
|
}
|
||||||
|
finally { $ErrorActionPreference = $old }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Ui {
|
||||||
|
[void](Adb-Shell @("uiautomator", "dump", "/sdcard/ui.xml"))
|
||||||
|
$old = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try { & $adb pull /sdcard/ui.xml "$env:TEMP\cap-ui.xml" 2>$null | Out-Null }
|
||||||
|
finally { $ErrorActionPreference = $old }
|
||||||
|
if (-not (Test-Path "$env:TEMP\cap-ui.xml")) { return "" }
|
||||||
|
return [System.IO.File]::ReadAllText("$env:TEMP\cap-ui.xml", [System.Text.Encoding]::UTF8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-Live([int]$Seconds) {
|
||||||
|
$deadline = (Get-Date).AddSeconds($Seconds)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 900
|
||||||
|
$x = Get-Ui
|
||||||
|
if ($x -match "RTT=") { return $x }
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shot([string]$name) {
|
||||||
|
$out = Join-Path $ev $name
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out $out
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $ev | Out-Null
|
||||||
|
# drop the earlier broken captures (PS 5.1 '>' wrote them as UTF-16)
|
||||||
|
Get-ChildItem $ev -Filter "*.png" | ForEach-Object {
|
||||||
|
$b = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||||
|
if ($b.Length -lt 4 -or ($b[0] -ne 0x89)) { Remove-Item $_.FullName -Force }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== ensure fresh v3 agent ==="
|
||||||
|
$agentExe = "$repo\target\release\agent.exe"
|
||||||
|
$ag = Get-Process agent -ErrorAction SilentlyContinue
|
||||||
|
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
|
||||||
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 800; $ag = $null
|
||||||
|
}
|
||||||
|
if (-not $ag) {
|
||||||
|
Start-Process $agentExe -ArgumentList "--port","47800","--code","t" -WindowStyle Hidden
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 1. connect form (V4 entry state) ==="
|
||||||
|
[void](Adb-Shell @("am","force-stop","com.verify.remote"))
|
||||||
|
[void](Adb-Shell @("am","start","-n","com.verify.remote/.MainActivity"))
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
$ui = Get-Ui
|
||||||
|
if ($ui -notmatch "android\.widget\.EditText") { throw "form fields missing" }
|
||||||
|
Shot "4b-form.png"
|
||||||
|
|
||||||
|
Write-Host "=== 2. failure path (V5): unreachable host via intent ==="
|
||||||
|
[void](Adb-Shell @("am","force-stop","com.verify.remote"))
|
||||||
|
[void](Adb-Shell @("am","start","-n","com.verify.remote/.MainActivity",
|
||||||
|
"--es","host","192.168.1.199","--ei","port","47800","--es","code","t"))
|
||||||
|
# Measured 2026-10-30: an unreachable host returns to the form with a concrete
|
||||||
|
# reason after ~32s (quinn's default connect timeout). Budget 50s, not 30s -
|
||||||
|
# the earlier 30s budget made this step look like a failure when it was only
|
||||||
|
# still inside the timeout. UX follow-up (logged in the result record): during
|
||||||
|
# those 30s the panel says only "connecting" with no cancel affordance.
|
||||||
|
$deadline = (Get-Date).AddSeconds(50)
|
||||||
|
$err = ""
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 1000
|
||||||
|
$ui = Get-Ui
|
||||||
|
if ($ui -match "android\.widget\.EditText") {
|
||||||
|
$shown = [regex]::Matches($ui, 'text="([^"]+)"') | ForEach-Object { $_.Groups[1].Value }
|
||||||
|
$err = $shown -join " | "
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($err) { Shot "4b-v5-error.png" } else { throw "V5: never returned to form with an error" }
|
||||||
|
Write-Host " error state visible: yes"
|
||||||
|
|
||||||
|
Write-Host "=== 3. live session (V4 via intent) ==="
|
||||||
|
[void](Adb-Shell @("am","force-stop","com.verify.remote"))
|
||||||
|
[void](Adb-Shell @("am","start","-n","com.verify.remote/.MainActivity",
|
||||||
|
"--es","host",$phoneIp,"--ei","port","47800","--es","code","t"))
|
||||||
|
$ui = Wait-Live 25
|
||||||
|
if (-not $ui) { throw "session never became live" }
|
||||||
|
Shot "4b-connected-stream.png"
|
||||||
|
Write-Host " $([regex]::Match($ui,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
|
||||||
|
|
||||||
|
Write-Host "=== 4. keyboard shown (real IME bound to our InputConnection) ==="
|
||||||
|
$btns = [regex]::Matches($ui, '<node[^>]*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if ($btns.Count -lt 1) { throw "no keyboard button" }
|
||||||
|
$b0 = $btns[0]
|
||||||
|
$kx = [int](([int]$b0.Groups[1].Value + [int]$b0.Groups[3].Value) / 2)
|
||||||
|
$ky = [int](([int]$b0.Groups[2].Value + [int]$b0.Groups[4].Value) / 2)
|
||||||
|
[void](Adb-Shell @("input","tap","$kx","$ky"))
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
$ime = (Adb-Shell @("dumpsys","input_method")).out
|
||||||
|
if ($ime -notmatch "mInputShown=true") { throw "IME not shown" }
|
||||||
|
if ($ime -notmatch "mHaveConnection=true") { throw "no InputConnection bound" }
|
||||||
|
Shot "4b-ime-shown.png"
|
||||||
|
|
||||||
|
Write-Host "=== 5. IME commit reaches the pipeline (diagnostic shows commit) ==="
|
||||||
|
[void](Adb-Shell @("input","text","hello"))
|
||||||
|
Start-Sleep -Milliseconds 1200
|
||||||
|
[void](Adb-Shell @("input","keyevent","62")) # SPACE: accept any pending candidate
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$ui = Get-Ui
|
||||||
|
$diag = [regex]::Match($ui, 'text="([^"]*RTT=[^"]*)"').Groups[1].Value
|
||||||
|
Write-Host " phone status: $diag"
|
||||||
|
Shot "4b-ime-committed.png"
|
||||||
|
|
||||||
|
Write-Host "=== 6. disconnect (back key) ==="
|
||||||
|
[void](Adb-Shell @("input","keyevent","4")) # dismiss keyboard
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
[void](Adb-Shell @("input","keyevent","4")) # disconnect
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$ui = Get-Ui
|
||||||
|
if ($ui -match "RTT=") { throw "still connected after two BACK presses" }
|
||||||
|
Shot "4b-disconnected.png"
|
||||||
|
Write-Host "=== CAPTURE COMPLETE ==="
|
||||||
|
Get-ChildItem $ev -Filter "4b-*.png" | ForEach-Object {
|
||||||
|
$b = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||||
|
$sig = ($b[0..3] | ForEach-Object { $_.ToString("X2") }) -join " "
|
||||||
|
"{0,-28} {1,9} {2}" -f $_.Name, $b.Length, $sig
|
||||||
|
}
|
||||||
432
tools/connect_lifecycle_proof.ps1
Normal file
432
tools/connect_lifecycle_proof.ps1
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
# 3a connect-lifecycle proof (phone side): U8 timeout/cancel, error classes, BACK-cancel, HUD.
|
||||||
|
#
|
||||||
|
# ASCII-only source (PS 5.1 decodes BOM-less .ps1 as GBK; see personal lessons).
|
||||||
|
# Expected Chinese UI strings are matched as CODE-POINT fragments extracted from
|
||||||
|
# the Kotlin sources at authoring time (R-8: assert the actual value, and print
|
||||||
|
# what the parser really read so an empty result cannot pass silently).
|
||||||
|
#
|
||||||
|
# Judgements (V-A1 rewritten per loop-3-plan D3 redial ruling, user 2026-10-31):
|
||||||
|
# V-A2a wrong code -> AUTH class text + original-error detail line
|
||||||
|
# V-A1a unresolvable host -> RESOLVE text fast (<3s; Resolve is NOT retryable)
|
||||||
|
# V-A1b blackhole IP -> connecting shows CANCEL; tap -> actionable form <1.5s
|
||||||
|
# V-A4 BACK while connecting -> cancel to form, activity NOT finished
|
||||||
|
# V-A5 live session -> HUD has fps= and no coordinate noise (norm=/view=)
|
||||||
|
#
|
||||||
|
# The IME is disabled for the whole run (form fields then receive raw key events,
|
||||||
|
# no soft keyboard covering the button; same pattern as phone_chain_proof step 6)
|
||||||
|
# and restored in `finally`.
|
||||||
|
#
|
||||||
|
# Prereqs: vivo device online via adb, 3a APK installed, v3 agent build present.
|
||||||
|
# powershell -ExecutionPolicy Bypass -File tools\connect_lifecycle_proof.ps1 -Host_ 192.168.1.110
|
||||||
|
param(
|
||||||
|
[string]$Host_ = "192.168.1.110",
|
||||||
|
[int]$Port = 47800,
|
||||||
|
[string]$Code = "t",
|
||||||
|
[string]$AgentExe = ""
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = (Resolve-Path "$PSScriptRoot\..").Path
|
||||||
|
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||||
|
$ev = Join-Path (Split-Path $repo -Parent) "evidence\3a"
|
||||||
|
New-Item -ItemType Directory -Force -Path $ev | Out-Null
|
||||||
|
|
||||||
|
# ---- code-point fragments extracted from Kotlin sources THIS authoring session
|
||||||
|
# (grep-verified present in ConnectErrorText.kt / MainActivity.kt): ----
|
||||||
|
# NB: build with `-join (...)`, NOT `[string](a,b)` - the latter stringifies the
|
||||||
|
# array with spaces ("连 接", len 3) and silently never matches (measured
|
||||||
|
# 2026-10-31; visibleTexts comparison then finds 'no button' for a button that
|
||||||
|
# is plainly on screen).
|
||||||
|
$F_RESOLVE = -join ([char]0x65E0,[char]0x6CD5,[char]0x89E3,[char]0x6790,[char]0x8FD9,[char]0x4E2A,[char]0x5730,[char]0x5740) # "cannot resolve this address"
|
||||||
|
$F_AUTH = -join ([char]0x9A8C,[char]0x8BC1,[char]0x7801,[char]0x9519,[char]0x8BEF) # "verification code wrong"
|
||||||
|
$F_CANCELED = -join ([char]0x8FDE,[char]0x63A5,[char]0x5DF2,[char]0x53D6,[char]0x6D88) # "connection cancelled"
|
||||||
|
$F_RECONNECT = -join ([char]0x91CD,[char]0x65B0,[char]0x8FDE,[char]0x63A5) # "reconnect"
|
||||||
|
$F_CANCEL = -join ([char]0x53D6,[char]0x6D88) # "cancel"
|
||||||
|
$F_CONNECT = -join ([char]0x8FDE,[char]0x63A5) # "connect"
|
||||||
|
$F_KILLMSG = -join ([char]0x8FDE,[char]0x63A5,[char]0x5DF2,[char]0x65AD,[char]0x5F00) # "connection lost"
|
||||||
|
|
||||||
|
function Adb {
|
||||||
|
param([Parameter(Mandatory)][string[]]$AdbArgs)
|
||||||
|
$old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
||||||
|
try { $out = & $adb @AdbArgs 2>$null; @{ out = ($out -join "`n"); code = $LASTEXITCODE } }
|
||||||
|
finally { $ErrorActionPreference = $old }
|
||||||
|
}
|
||||||
|
# NB: the concat must be parenthesized. `Adb @("shell") + $Cmd` parses `+` as a
|
||||||
|
# literal extra argument (measured 2026-10-31: every shell call silently ran
|
||||||
|
# `adb shell + ...` on the device, returning empty output - the form 'vanished'
|
||||||
|
# because uiautomator never actually ran).
|
||||||
|
function AdbShell {
|
||||||
|
param([string[]]$Cmd)
|
||||||
|
(Adb -AdbArgs (@("shell") + $Cmd)).out
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-UiText {
|
||||||
|
$d = AdbShell @("uiautomator","dump","/sdcard/cl.xml")
|
||||||
|
$r = Adb @("pull","/sdcard/cl.xml","$env:TEMP\cl.xml")
|
||||||
|
if ($r.code -ne 0 -or -not (Test-Path "$env:TEMP\cl.xml")) {
|
||||||
|
Write-Host " [ui] dump=[$($d -replace "`n",' ')] pull_rc=$($r.code) -> empty"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
$txt = [System.IO.File]::ReadAllText("$env:TEMP\cl.xml", [System.Text.Encoding]::UTF8)
|
||||||
|
if ($txt.Length -lt 200) {
|
||||||
|
# dump can 'succeed' yet hand back a stale stub; show what we really read
|
||||||
|
Write-Host " [ui] short xml ($($txt.Length) chars): $($txt.Substring(0,[Math]::Min(120,$txt.Length)))"
|
||||||
|
}
|
||||||
|
return $txt
|
||||||
|
}
|
||||||
|
function VisibleTexts([string]$xml) {
|
||||||
|
[regex]::Matches($xml,'text="([^"]+)"') | ForEach-Object { [System.Net.WebUtility]::HtmlDecode($_.Groups[1].Value) }
|
||||||
|
}
|
||||||
|
function FormFields([string]$xml) {
|
||||||
|
[regex]::Matches($xml,'<node[^>]*class="android\.widget\.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
}
|
||||||
|
function Center($m) {
|
||||||
|
# NB: cast to [int] BEFORE adding. A regex group .Value is a STRING and PS
|
||||||
|
# "60" + "1020" CONCATENATES ("601020") -> /2 then lands the tap ~300000px
|
||||||
|
# off-screen, silently. Measured 2026-10-31; phone_chain_proof's Center is
|
||||||
|
# the proven form - copy it, don't re-derive.
|
||||||
|
$a=[int]$m.Groups[1].Value; $b=[int]$m.Groups[2].Value
|
||||||
|
$c=[int]$m.Groups[3].Value; $d=[int]$m.Groups[4].Value
|
||||||
|
@([int](($a+$c)/2), [int](($b+$d)/2))
|
||||||
|
}
|
||||||
|
function ButtonCenter([string]$xml, [string]$want) {
|
||||||
|
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||||
|
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||||
|
if ($t -eq $want) {
|
||||||
|
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if ($b.Success) {
|
||||||
|
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
|
||||||
|
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
|
||||||
|
return @([int](($x1+$x2)/2), [int](($y1+$y2)/2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Physical-key input with the IME disabled. Key codes from javap against
|
||||||
|
# android-34 (same table phone_chain_proof uses): KEYCODE_0=7..9=16,
|
||||||
|
# KEYCODE_A=29..Z=54 contiguous (T=48 verified there), PERIOD=56.
|
||||||
|
# NB1: [int]'A' converts the STRING and throws; letter anchor is [int][char]'A'.
|
||||||
|
# NB2: [int][string]'0' == 0 (parse), so digits map 7+digit (an earlier draft
|
||||||
|
# got this wrong twice: `-48`, then MINUS=14 - which is KEYCODE_7!).
|
||||||
|
# MINUS (69) is deliberately NOT supported: throw instead of typing a wrong
|
||||||
|
# key (measured 2026-10-31: a wrong MINUS mapping silently typed '7' into
|
||||||
|
# the code field and corrupted the whole fill sequence).
|
||||||
|
function TypeAscii([string]$s) {
|
||||||
|
foreach ($ch in $s.ToUpper().ToCharArray()) {
|
||||||
|
$k = if ($ch -ge '0' -and $ch -le '9') { 7 + [int][string]$ch }
|
||||||
|
elseif ($ch -ge 'A' -and $ch -le 'Z') { 29 + ([int]$ch - [int][char]'A') }
|
||||||
|
elseif ($ch -eq '.') { 56 }
|
||||||
|
else { throw "unsupported char: $ch" }
|
||||||
|
[void](AdbShell @("input","keyevent","$k"))
|
||||||
|
# 35ms lost a digit on the live device (port arrived as "4780");
|
||||||
|
# 70ms gap + per-field verify-and-retry below is the honest fix.
|
||||||
|
Start-Sleep -Milliseconds 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# which field (0..2) currently holds focus? "" if none.
|
||||||
|
function FocusedIndex([string]$xml) {
|
||||||
|
$i = -1
|
||||||
|
foreach ($n in [regex]::Matches($xml,'<node[^>]*>')) {
|
||||||
|
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
||||||
|
$i++
|
||||||
|
if ($n.Value -match 'focused="true"') { return $i }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tap a field's center and VERIFY focus landed there (2b N-series discipline:
|
||||||
|
# automation must assert its own preconditions - adb taps are not guaranteed).
|
||||||
|
function Focus-Field($c, [int]$wantIdx) {
|
||||||
|
for ($try=0; $try -lt 3; $try++) {
|
||||||
|
[void](AdbShell @("input","tap","$($c[0])","$($c[1])"))
|
||||||
|
$deadline = (Get-Date).AddSeconds(1.5)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
if ((FocusedIndex (Get-UiText)) -eq $wantIdx) { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw "tap did not focus field $wantIdx after 3 tries (focused=$(FocusedIndex (Get-UiText)))"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Clear-Field($c, [int]$idx) {
|
||||||
|
Focus-Field $c $idx
|
||||||
|
for ($i=0; $i -lt 40; $i++) { [void](AdbShell @("input","keyevent","67")) }
|
||||||
|
Start-Sleep -Milliseconds 150
|
||||||
|
}
|
||||||
|
|
||||||
|
function Restart-AppForm {
|
||||||
|
# vivo keeps re-sleeping mid-run (measured 2026-10-31: battery-saver + a 7s
|
||||||
|
# screen-off timeout re-locked the keyguard between steps, so uiautomator saw
|
||||||
|
# the always-on-display clock instead of the app and 'form did not appear').
|
||||||
|
# Wake + dismiss is idempotent and keeps the harness unattended-safe; if the
|
||||||
|
# device is PIN-locked a bare wake is not enough and this will fail loudly.
|
||||||
|
[void](AdbShell @("input","keyevent","224")) # WAKEUP
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
[void](AdbShell @("wm","dismiss-keyguard"))
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
[void](AdbShell @("am","force-stop","com.verify.remote"))
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
[void](AdbShell @("am","start","-n","com.verify.remote/.MainActivity")) # no extras -> form
|
||||||
|
$deadline = (Get-Date).AddSeconds(8)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$x = Get-UiText
|
||||||
|
if ((FormFields $x).Count -ge 3) { return $x }
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
}
|
||||||
|
$diag = (VisibleTexts (Get-UiText)) -join " || "
|
||||||
|
throw "connect form did not appear (expected >=3 EditText). screen shows: [$diag]"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fill host/port/code into the 3 form fields, asserting every precondition
|
||||||
|
# (R-8 + 2026-10-31 lesson: an adb tap does NOT guarantee focus - one failed run
|
||||||
|
# left all three type-ins in field 0 and the single 'vals[0] -ne host' check
|
||||||
|
# caught it only as a corruption symptom). Now: focus is verified per field, and
|
||||||
|
# every field's final text must equal its intended value. Returns tap time.
|
||||||
|
function FillAndConnect([string]$xml, [string]$host_, [string]$port_, [string]$code_) {
|
||||||
|
$f = FormFields $xml
|
||||||
|
if ($f.Count -lt 3) { throw "form has $($f.Count) fields" }
|
||||||
|
# NB: do NOT gather the three centers into @(a,b,c) - PowerShell array
|
||||||
|
# literals FLATTEN nested arrays, silently turning 3 coord-pairs into 6 ints;
|
||||||
|
# $cs[0] then holds only the x of field 0, every tap lands at a bogus point,
|
||||||
|
# focus never moves (measured 2026-10-31: 'focused=-1' was the honest symptom).
|
||||||
|
# Pass each call's array result directly as ONE argument.
|
||||||
|
Clear-Field (Center $f[0]) 0; TypeAscii $host_
|
||||||
|
Clear-Field (Center $f[1]) 1; TypeAscii $port_
|
||||||
|
Clear-Field (Center $f[2]) 2; TypeAscii $code_
|
||||||
|
# Verify ALL THREE fields hold their intended value; on mismatch, retype the
|
||||||
|
# offending field and re-check (max 2 retries) - a dropped key event on the
|
||||||
|
# live device is a known flake (measured: port typed as "4780"), and the
|
||||||
|
# final judgement must still be 'equals expected', never 'close enough'.
|
||||||
|
$expect = @($host_, $port_, $code_)
|
||||||
|
for ($retry=0; $retry -le 2; $retry++) {
|
||||||
|
$x2 = Get-UiText
|
||||||
|
$vals = @()
|
||||||
|
foreach ($n in [regex]::Matches($x2,'<node[^>]*>')) {
|
||||||
|
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
||||||
|
$vals += [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host " form now: $($vals -join ' | ') (try $retry)"
|
||||||
|
if ($vals.Count -lt 3) { throw "expected 3 fields after typing, got $($vals.Count)" }
|
||||||
|
$bad = -1
|
||||||
|
for ($i=0; $i -lt 3; $i++) { if ($vals[$i] -ne $expect[$i]) { $bad = $i; break } }
|
||||||
|
if ($bad -ge 0) {
|
||||||
|
$f = FormFields $x2
|
||||||
|
if ($f.Count -lt 3) { throw "form lost fields during retry" }
|
||||||
|
Clear-Field (Center $f[$bad]) $bad
|
||||||
|
TypeAscii $expect[$bad]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ($vals[0] -ne $host_) { throw "form input FAIL: host field is [$($vals[0])], expected [$host_]" }
|
||||||
|
if ($vals[1] -ne $port_) { throw "form input FAIL: port field is [$($vals[1])], expected [$port_]" }
|
||||||
|
if ($vals[2] -ne $code_) { throw "form input FAIL: code field is [$($vals[2])], expected [$code_]" }
|
||||||
|
$btn = $null
|
||||||
|
foreach ($want in @($F_CONNECT, $F_RECONNECT)) { $btn = ButtonCenter $x2 $want; if ($btn) { break } }
|
||||||
|
if (-not $btn) { throw "no primary connect/reconnect button found" }
|
||||||
|
$t = Get-Date
|
||||||
|
[void](AdbShell @("input","tap","$($btn[0])","$($btn[1])"))
|
||||||
|
return $t
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-Connecting([string]$blackhole, [double]$seconds) {
|
||||||
|
# connecting state = remote panel up, bottom bar shows the CANCEL button
|
||||||
|
$deadline = (Get-Date).AddSeconds($seconds)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$x = Get-UiText
|
||||||
|
if (ButtonCenter $x $F_CANCEL) { return $x }
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
throw "never entered connecting state (no CANCEL button within ${seconds}s)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 0. stale-agent guard (R-7) + IME disable (restore in finally) ==="
|
||||||
|
if (-not $AgentExe) { $AgentExe = Join-Path $repo "target\release\agent.exe" }
|
||||||
|
$ag = @(Get-Process agent -ErrorAction SilentlyContinue)
|
||||||
|
if ($ag.Count -gt 0 -and (Test-Path $AgentExe) -and ((Get-Item $AgentExe).LastWriteTime -gt $ag[0].StartTime)) {
|
||||||
|
Write-Host " running agent predates current build; restarting"
|
||||||
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 800; $ag = @()
|
||||||
|
}
|
||||||
|
if ($ag.Count -eq 0) {
|
||||||
|
if (-not (Test-Path $AgentExe)) { throw "missing $AgentExe (cargo build --release -p agent)" }
|
||||||
|
Start-Process $AgentExe -ArgumentList "--port","$Port","--code",$Code -WindowStyle Hidden | Out-Null
|
||||||
|
Start-Sleep -Seconds 1; Write-Host " agent started (v3)"
|
||||||
|
} else { Write-Host " agent running and current" }
|
||||||
|
|
||||||
|
$origIme = ((AdbShell @("settings","get","secure","default_input_method")) -join "").Trim()
|
||||||
|
Write-Host " original IME: [$origIme] (N13 discipline: recorded, restored in finally)"
|
||||||
|
if ($origIme -and $origIme -ne "null") { [void](AdbShell @("ime","disable",$origIme)) }
|
||||||
|
# keep the screen awake for the whole run (measured 2026-10-31: mid-run screen
|
||||||
|
# timeout turned every later tap/keyevent into a no-op against the AOD - the
|
||||||
|
# 'cancel did not return to form' FAIL was the sleep, not the app; a second
|
||||||
|
# probe reproduced it with dump texts showing clock+battery instead of the app)
|
||||||
|
[void](AdbShell @("svc","power","stayon","true"))
|
||||||
|
|
||||||
|
try {
|
||||||
|
# =================================================================
|
||||||
|
Write-Host "=== 1. V-A2a: wrong code -> AUTH class text + detail line ==="
|
||||||
|
$x = Restart-AppForm
|
||||||
|
[void](FillAndConnect $x $Host_ "$Port" "wrongcode3a")
|
||||||
|
$deadline = (Get-Date).AddSeconds(8); $seenAuth = $false; $seenDetail = $false; $shown = ""
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
$shown = ((VisibleTexts (Get-UiText)) -join " || ")
|
||||||
|
if ($shown.Contains($F_AUTH)) { $seenAuth = $true }
|
||||||
|
if ($shown.Contains("detail:")) { $seenDetail = $true }
|
||||||
|
if ($seenAuth) { break }
|
||||||
|
}
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-a2-auth.png" | Out-Null
|
||||||
|
Write-Host " screen: $shown"
|
||||||
|
if (-not $seenAuth) { throw "V-A2a FAIL: no AUTH text ('$F_AUTH'); got [$shown]" }
|
||||||
|
if (-not $seenDetail) { throw "V-A2a FAIL: AUTH shown but no 'detail:' original-error line; got [$shown]" }
|
||||||
|
Write-Host " PASS: wrong code -> '$F_AUTH' + detail line"
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
Write-Host "=== 2. V-A1a: unresolvable host -> RESOLVE fast (NOT redialed) ==="
|
||||||
|
$x = Restart-AppForm
|
||||||
|
$t = FillAndConnect $x "999.999.999.999" "$Port" $Code
|
||||||
|
$deadline = $t.AddSeconds(3); $seen = $false; $shown = ""; $elapsed = -1
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
|
$shown = ((VisibleTexts (Get-UiText)) -join " || ")
|
||||||
|
if ($shown.Contains($F_RESOLVE)) { $seen = $true; $elapsed = ((Get-Date) - $t).TotalSeconds; break }
|
||||||
|
}
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-a1-resolve.png" | Out-Null
|
||||||
|
Write-Host " screen: $shown (after ${elapsed}s)"
|
||||||
|
if (-not $seen) { throw "V-A1a FAIL: no RESOLVE text ('$F_RESOLVE') within 3s; got [$shown]" }
|
||||||
|
Write-Host " PASS: unresolvable host -> '$F_RESOLVE' in ${elapsed}s (<3s proves no redial)"
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
Write-Host "=== 3. V-A1b: blackhole IP -> CANCEL tap -> 'cancelled' form ==="
|
||||||
|
# Intent path (2026-10-31 lesson): with the form path, taps during connecting
|
||||||
|
# proved unreliable in THIS app - the EditText still holds focus when the
|
||||||
|
# panel swaps (the form's own click chain leaves focus where it was), and the
|
||||||
|
# first tap only defocuses: manual form-path probe lost the tap 4 rounds in a
|
||||||
|
# row, while the intent path (no EditText ever focused) took ONE tap and
|
||||||
|
# returned '连接已取消' instantly. Intent auto-connect reaches the very same
|
||||||
|
# connecting state (same startConnect code), so it isolates the product
|
||||||
|
# behaviour from keyboard/focus noise.
|
||||||
|
[void](AdbShell @("input","keyevent","224"))
|
||||||
|
[void](AdbShell @("wm","dismiss-keyguard"))
|
||||||
|
[void](AdbShell @("am","force-stop","com.verify.remote"))
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
[void](AdbShell @("am","start","-n","com.verify.remote/.MainActivity",
|
||||||
|
"--es","host","192.0.2.1","--ei","port","$Port","--es","code",$Code))
|
||||||
|
$xc = Wait-Connecting "192.0.2.1" 6
|
||||||
|
$cancel = ButtonCenter $xc $F_CANCEL
|
||||||
|
if (-not $cancel) { throw "V-A1b FAIL: no CANCEL button in intent connecting state" }
|
||||||
|
$tc = Get-Date
|
||||||
|
[void](AdbShell @("input","tap","$($cancel[0])","$($cancel[1])"))
|
||||||
|
$deadline = $tc.AddSeconds(3); $back = $false; $shown = ""
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
$x2 = Get-UiText
|
||||||
|
$shown = ((VisibleTexts $x2) -join " || ")
|
||||||
|
# judgement = the CANCEL-triggered text specifically (F_CANCELED), not
|
||||||
|
# merely 'back at the form' - the 8s budget would also return a form
|
||||||
|
# (with the timeout text) and that must not masquerade as a cancel PASS.
|
||||||
|
if ((FormFields $x2).Count -ge 3 -and $shown.Contains($F_CANCELED)) { $back = $true; break }
|
||||||
|
}
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-a1-cancel.png" | Out-Null
|
||||||
|
Write-Host " after cancel: $shown"
|
||||||
|
if (-not $back) { throw "V-A1b FAIL: cancel tap did not surface '$F_CANCELED' within 3s; got [$shown]" }
|
||||||
|
$ms = [int]((Get-Date).Subtract($tc).TotalMilliseconds)
|
||||||
|
Write-Host " PASS: connecting -> CANCEL -> '$F_CANCELED' form in ${ms}ms"
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
Write-Host "=== 4. V-A4: BACK while connecting -> cancel, activity survives ==="
|
||||||
|
# same intent-path rationale as step 3 (BACK also needs the app window to
|
||||||
|
# own input focus; a focused EditText would eat the first gesture instead)
|
||||||
|
[void](AdbShell @("input","keyevent","224"))
|
||||||
|
[void](AdbShell @("wm","dismiss-keyguard"))
|
||||||
|
[void](AdbShell @("am","force-stop","com.verify.remote"))
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
[void](AdbShell @("am","start","-n","com.verify.remote/.MainActivity",
|
||||||
|
"--es","host","192.0.2.1","--ei","port","$Port","--es","code",$Code))
|
||||||
|
[void](Wait-Connecting "192.0.2.1" 6)
|
||||||
|
[void](AdbShell @("input","keyevent","4")) # BACK
|
||||||
|
$deadline = (Get-Date).AddSeconds(3); $atForm = $false; $shown = ""
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
$x2 = Get-UiText
|
||||||
|
$shown = ((VisibleTexts $x2) -join " || ")
|
||||||
|
if ((FormFields $x2).Count -ge 3 -and $shown.Contains($F_CANCELED)) { $atForm = $true; break }
|
||||||
|
}
|
||||||
|
$focusLine = ((AdbShell @("dumpsys","window")) -join "`n")
|
||||||
|
$alive = $focusLine -match "com\.verify\.remote"
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-a4-back.png" | Out-Null
|
||||||
|
Write-Host " after BACK: activityAlive=$alive atForm=$atForm"
|
||||||
|
Write-Host " screen: $shown"
|
||||||
|
if (-not $alive) { throw "V-A4 FAIL: BACK during connect finished the activity (old super.onKeyDown regression)" }
|
||||||
|
if (-not $atForm) { throw "V-A4 FAIL: BACK during connect did not surface '$F_CANCELED' on the form" }
|
||||||
|
Write-Host " PASS: BACK during connect -> cancel + form, activity kept"
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
Write-Host "=== 5. V-A5: live session HUD: fps= present, no coordinate noise ==="
|
||||||
|
$x = Restart-AppForm
|
||||||
|
[void](FillAndConnect $x $Host_ "$Port" $Code)
|
||||||
|
$deadline = (Get-Date).AddSeconds(12); $live = $false; $hud = ""
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$m = [regex]::Match((Get-UiText),'text="([^"]*RTT=[^"]*)"')
|
||||||
|
if ($m.Success) { $hud = [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value); $live = $true; break }
|
||||||
|
}
|
||||||
|
$hasFps = $hud.Contains("fps=")
|
||||||
|
$noNoise = -not ($hud.Contains("norm=") -or $hud.Contains("view="))
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-a5-hud.png" | Out-Null
|
||||||
|
Write-Host " HUD: $hud"
|
||||||
|
if (-not $live) { throw "V-A5 FAIL: session never reached live state" }
|
||||||
|
if (-not $hasFps) { throw "V-A5 FAIL: HUD missing fps= ; got [$hud]" }
|
||||||
|
if (-not $noNoise) { throw "V-A5 FAIL: HUD carries coordinate noise (norm=/view=)" }
|
||||||
|
Write-Host " PASS: live HUD shows fps, no coordinate noise"
|
||||||
|
|
||||||
|
Write-Host "=== 5b. V-A3 re-check: kill agent -> 'lost' form within ~16s ==="
|
||||||
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||||
|
$deadline = (Get-Date).AddSeconds(16); $lost = $false; $shown = ""
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$x3 = Get-UiText
|
||||||
|
$shown = ((VisibleTexts $x3) -join " || ")
|
||||||
|
if ($shown.Contains($F_KILLMSG) -and (FormFields $x3).Count -ge 3) { $lost = $true; break }
|
||||||
|
}
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-a3-lost.png" | Out-Null
|
||||||
|
Write-Host " after kill: $shown"
|
||||||
|
if (-not $lost) { throw "V-A3 FAIL: kill did not surface '$F_KILLMSG' form within 16s" }
|
||||||
|
Write-Host " PASS: agent killed -> lost-session surfaces on form (idle-timeout bound)"
|
||||||
|
|
||||||
|
Write-Host "=== 6. reconnect to a fresh agent (self-heal path) ==="
|
||||||
|
Start-Process $AgentExe -ArgumentList "--port","$Port","--code",$Code -WindowStyle Hidden | Out-Null
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$x = Get-UiText
|
||||||
|
$btn = ButtonCenter $x $F_RECONNECT
|
||||||
|
if (-not $btn) { $x = Restart-AppForm; $btn = $null }
|
||||||
|
if ($btn) {
|
||||||
|
[void](AdbShell @("input","tap","$($btn[0])","$($btn[1])"))
|
||||||
|
$deadline = (Get-Date).AddSeconds(14); $live2 = $false
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
if ((Get-UiText) -match "RTT=") { $live2 = $true; break }
|
||||||
|
}
|
||||||
|
if (-not $live2) { throw "step 6 FAIL: reconnect button did not reach a live session" }
|
||||||
|
Write-Host " PASS: one-tap reconnect after agent restart works"
|
||||||
|
[void](AdbShell @("input","keyevent","4")) # disconnect, free the slot
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
} else {
|
||||||
|
Write-Host " (no live reconnect path exercised this run - covered by manual run)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
[void](AdbShell @("svc","power","stayon","false")) # undo the keep-awake above
|
||||||
|
if ($origIme -and $origIme -ne "null") {
|
||||||
|
[void](AdbShell @("ime","enable",$origIme))
|
||||||
|
[void](AdbShell @("ime","set",$origIme))
|
||||||
|
Write-Host " IME restored: $(((AdbShell @('settings','get','secure','default_input_method')) -join '').Trim())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== CONNECT LIFECYCLE PROOF COMPLETE ==="
|
||||||
280
tools/cursor_proof.ps1
Normal file
280
tools/cursor_proof.ps1
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
# 4b cursor-overlay proof: marker position, off-screen fold, refresh, old-client reject.
|
||||||
|
#
|
||||||
|
# ASCII-only (PS 5.1 reads BOM-less .ps1 as GBK; repo lessons). Chinese expected
|
||||||
|
# texts are built from code points (R-8: never trust an encoding round trip).
|
||||||
|
#
|
||||||
|
# Judgements (loop-4b-plan.md section 9):
|
||||||
|
# V-D6 OLD v4 APK + new v5 agent -> form shows the version-mismatch text and
|
||||||
|
# the session never goes live. RUN THIS PHASE BEFORE INSTALLING THE NEW
|
||||||
|
# APK: -Phase reject
|
||||||
|
# V-D1 screencap cyan cluster centroid == CursorMarker expectation +/- 6 px
|
||||||
|
# for 4 desktop points incl. (48,48) ~ origin (Some(0,0) vs None: the
|
||||||
|
# marker MUST be painted at the corner, not skipped)
|
||||||
|
# V-D3 after moving the PC cursor on a quiet desktop the marker reaches the
|
||||||
|
# new spot within ~1.2s (covers the 500ms idle-resend path; the frame
|
||||||
|
# header rebuild with fresh cursor was the whole point of R-A)
|
||||||
|
# V-D7 cursor into the LEFT SECOND MONITOR (negative X, machine is dual-screen
|
||||||
|
# since 2026-11-02) -> agent folds to None -> cyan cluster GONE
|
||||||
|
#
|
||||||
|
# Prereqs: v5 release agent.exe built; new (v5) APK installed for -Phase main;
|
||||||
|
# second monitor actually present (V-D7 skips loudly if not - see probe output);
|
||||||
|
# nobody touching the real mouse during a step (gates retry, loud fail otherwise).
|
||||||
|
#
|
||||||
|
# powershell -ExecutionPolicy Bypass -File tools\cursor_proof.ps1 -Host_ 192.168.1.181 -Phase reject
|
||||||
|
# powershell -ExecutionPolicy Bypass -File tools\cursor_proof.ps1 -Host_ 192.168.1.181 -Phase main
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Host_,
|
||||||
|
[int]$Port = 47800,
|
||||||
|
[string]$Code = "t",
|
||||||
|
[ValidateSet("reject", "main")][string]$Phase = "main",
|
||||||
|
[string]$AgentExe = ""
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = (Resolve-Path "$PSScriptRoot\..").Path
|
||||||
|
. "$PSScriptRoot\proof_common.ps1"
|
||||||
|
|
||||||
|
# ---- expected Chinese fragments as code points (R-8) ----
|
||||||
|
# "connect" button label
|
||||||
|
$F_CONNECT = -join ([char]0x8FDE, [char]0x63A5)
|
||||||
|
# "reconnect"
|
||||||
|
$F_RECONNECT = -join ([char]0x91CD, [char]0x65B0, [char]0x8FDE, [char]0x63A5)
|
||||||
|
# "protocol version mismatch" error summary (ConnectErrorText KIND_VERSION)
|
||||||
|
$F_VERSION = -join ([char]0x534F, [char]0x8BAE, [char]0x7248, [char]0x672C,
|
||||||
|
[char]0x4E0D, [char]0x4E00, [char]0x81F4)
|
||||||
|
# "live" HUD prefix (yi-lian-jie = connected)
|
||||||
|
$F_LIVE = -join ([char]0x5DF2, [char]0x8FDE, [char]0x63A5)
|
||||||
|
|
||||||
|
$ev = Join-Path (Split-Path $repo -Parent) "evidence\4b"
|
||||||
|
New-Item -ItemType Directory -Force -Path $ev | Out-Null
|
||||||
|
|
||||||
|
# ---- cyan marker pixel detector (LockBits scan, full centroid) ----
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms # VirtualScreen for V-D7
|
||||||
|
if (-not ('PccCyan' -as [type])) {
|
||||||
|
Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @"
|
||||||
|
using System; using System.Drawing; using System.Drawing.Imaging;
|
||||||
|
using System.Runtime.InteropServices; using System.Collections.Generic;
|
||||||
|
public class PccCyan {
|
||||||
|
public static string Find(string path) {
|
||||||
|
using (var bmp = new Bitmap(path)) {
|
||||||
|
var rect = new Rectangle(0,0,bmp.Width,bmp.Height);
|
||||||
|
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||||
|
int n = data.Stride * data.Height; byte[] buf = new byte[n];
|
||||||
|
Marshal.Copy(data.Scan0, buf, 0, n);
|
||||||
|
bmp.UnlockBits(data);
|
||||||
|
// dot color = RGB(0x00,0xE5,0xFF); BGRA in memory. Loose tolerance:
|
||||||
|
// the ring is white/black; a desktop with a big cyan region would poison the
|
||||||
|
// centroid, so the total-count guard below rejects it instead of averaging.
|
||||||
|
int total = 0;
|
||||||
|
for (int y=0; y<data.Height; y++) {
|
||||||
|
int row = y*data.Stride;
|
||||||
|
for (int x=0; x<data.Width; x++) {
|
||||||
|
int i = row + x*4;
|
||||||
|
byte b=buf[i], g=buf[i+1], r=buf[i+2];
|
||||||
|
if (r < 40 && (g>=0xC0 && g<=0xFF) && b>=0xD0) total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (total == 0) return "none";
|
||||||
|
if (total > 4000) return "patch:" + total; // suspicious giant color field
|
||||||
|
// Centroid over ALL cyan pixels would be corrupted by a few stray ones far
|
||||||
|
// away (measured 2026-11-02: n=181 at the right spot but spread=955 - the
|
||||||
|
// desktop itself had a handful of cyan pixels elsewhere). Cluster properly:
|
||||||
|
// bin the hits on an 8px grid, take the DENSEST bin as the marker seed,
|
||||||
|
// then centroid/spread over hits within 40px of that seed only.
|
||||||
|
var bins = new Dictionary<long,int>();
|
||||||
|
for (int y=0; y<data.Height; y++) {
|
||||||
|
int row = y*data.Stride;
|
||||||
|
for (int x=0; x<data.Width; x++) {
|
||||||
|
int i = row + x*4;
|
||||||
|
byte b=buf[i], g=buf[i+1], r=buf[i+2];
|
||||||
|
if (r < 40 && (g>=0xC0 && g<=0xFF) && b>=0xD0) {
|
||||||
|
long key = (long)(x/8)*1000000 + (y/8);
|
||||||
|
bins[key] = bins.ContainsKey(key) ? bins[key]+1 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
long bestK = -1; int bestN = 0; int secondN = 0;
|
||||||
|
foreach (var kv in bins) {
|
||||||
|
if (kv.Value > bestN) { secondN = bestN; bestN = kv.Value; bestK = kv.Key; }
|
||||||
|
else if (kv.Value > secondN) { secondN = kv.Value; }
|
||||||
|
}
|
||||||
|
int bx = (int)(bestK/1000000)*8 + 4, by = (int)(bestK%1000000)*8 + 4;
|
||||||
|
long sx=0, sy=0; int cnt=0; int minx=99999999,miny=99999999,maxx=-1,maxy=-1;
|
||||||
|
for (int y=0; y<data.Height; y++) {
|
||||||
|
int row = y*data.Stride;
|
||||||
|
for (int x=0; x<data.Width; x++) {
|
||||||
|
int i = row + x*4;
|
||||||
|
byte b=buf[i], g=buf[i+1], r=buf[i+2];
|
||||||
|
if (r < 40 && (g>=0xC0 && g<=0xFF) && b>=0xD0) {
|
||||||
|
if (Math.Abs(x-bx)<=40 && Math.Abs(y-by)<=40) {
|
||||||
|
sx+=x; sy+=y; cnt++;
|
||||||
|
if(x<minx)minx=x; if(x>maxx)maxx=x; if(y<miny)miny=y; if(y>maxy)maxy=y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cnt == 0) return "none";
|
||||||
|
int spread = Math.Max(maxx-minx, maxy-miny);
|
||||||
|
// report seed too: harness prints it when a check fails (diagnosis, not judgement)
|
||||||
|
return string.Format("{0}|{1}|{2}|{3}|{4}|{5}|{6}", sx/(double)cnt, sy/(double)cnt, cnt, spread, total, bx, secondN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
function Find-Marker([string]$pngPath) {
|
||||||
|
$r = [PccCyan]::Find($pngPath)
|
||||||
|
if ($r -eq "none") { return $null }
|
||||||
|
if ($r -like "patch:*") { throw "screenshot contains a huge cyan-ish color field ($($r.Substring(6)) px) - clear the desktop (wallpaper/browser) and re-run; refusing to average a fake marker (R-8)" }
|
||||||
|
$p = $r.Split('|')
|
||||||
|
@{ x = [double]$p[0]; y = [double]$p[1]; n = [int]$p[2]; spread = [int]$p[3]; total = [int]$p[4]; seed = "$($p[5]),$($p[6])" }
|
||||||
|
}
|
||||||
|
function Take-Shot([string]$tag) {
|
||||||
|
$dev = "/sdcard/pcc_$PID.png"
|
||||||
|
$local = Join-Path $ev "$tag.png"
|
||||||
|
$old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
[void](AdbShell @("screencap", "-p", $dev))
|
||||||
|
$r = Adb -AdbArgs @("pull", $dev, $local)
|
||||||
|
} finally { $ErrorActionPreference = $old }
|
||||||
|
if ($r.code -ne 0 -or -not (Test-Path $local)) { throw "screencap/pull failed for $tag" }
|
||||||
|
return $local
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 0. preflight (memory / fresh v5 agent) ==="
|
||||||
|
Memory-Precheck
|
||||||
|
if (-not $AgentExe) { $AgentExe = Join-Path $repo "target\release\agent.exe" }
|
||||||
|
Ensure-FreshAgent $AgentExe $Port $Code
|
||||||
|
|
||||||
|
$installed = ((AdbShell @("dumpsys", "package", "com.verify.remote"))) -join "`n"
|
||||||
|
$vn = [regex]::Match($installed, 'versionName=(\S+)').Groups[1].Value
|
||||||
|
Write-Host " installed APK: $vn"
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
if ($Phase -eq "reject") {
|
||||||
|
Write-Host "=== V-D6: OLD (v4) APK against the NEW v5 agent must be rejected ==="
|
||||||
|
if ($vn -like "*phase4a*") {
|
||||||
|
Write-Host " (running against the v4 client - this phase MUST come before installing 4b)"
|
||||||
|
} else {
|
||||||
|
Write-Host " WARNING: installed APK is $vn - the v4-rejection test is only meaningful against a real v4 client"
|
||||||
|
}
|
||||||
|
# NOTE (first-run lesson 2026-11-02): launching via `am start --es host ...`
|
||||||
|
# does NOT dial: the intent only prefill-prefers stale SharedPreferences and
|
||||||
|
# the form just sits there ("未连接"). Rejection must be observed through the
|
||||||
|
# REAL dial path - type the form, tap connect, read the failure screen.
|
||||||
|
$x = Restart-AppForm
|
||||||
|
FillAndConnect $x $Host_ "$Port" $Code $F_CONNECT $F_RECONNECT
|
||||||
|
$deadline = (Get-Date).AddSeconds(25); $shown = $null
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
$ui = Get-UiRetry
|
||||||
|
$texts = (VisibleTexts $ui) -join " / "
|
||||||
|
if ($texts -match "version mismatch" -or $texts.Contains($F_VERSION)) { $shown = $texts; break }
|
||||||
|
if ($texts.Contains($F_LIVE)) { throw "V-D6 FAIL: v4 client CONNECTED to the v5 agent ('$F_LIVE' on screen) - version gate broken" }
|
||||||
|
}
|
||||||
|
if (-not $shown) { throw "V-D6 FAIL: no version-mismatch text within 25s; last screen: [$((VisibleTexts (Get-Ui)) -join ' / ')]" }
|
||||||
|
Write-Host " screen: $shown"
|
||||||
|
Write-Host " PASS: v5 agent refused the v4 client with the actionable Chinese hint"
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-d6-reject.png" | Out-Null
|
||||||
|
# pollution discipline (4a): leave no live session for the next proof
|
||||||
|
[void](AdbShell @("am", "force-stop", "com.verify.remote"))
|
||||||
|
Write-Host "=== CURSOR PROOF (reject phase) COMPLETE ==="
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# ---- main phase: needs the NEW apk; go live once, then geometry checks ----
|
||||||
|
Write-Host "=== 1. go live (new v5 APK), capture HUD (v5 pin + desktop size) ==="
|
||||||
|
if ($vn -notlike "*phase4b*") { throw "main phase needs the 4b APK installed (found $vn)" }
|
||||||
|
$x = Restart-AppForm
|
||||||
|
FillAndConnect $x $Host_ "$Port" $Code $F_CONNECT $F_RECONNECT
|
||||||
|
$hud = Wait-LiveHud 20
|
||||||
|
if (-not $hud) { throw "never reached live: [$((VisibleTexts (Get-Ui)) -join ' / ')]" }
|
||||||
|
Write-Host " HUD: $($hud -replace "`n", ' | ')"
|
||||||
|
if (-not $hud.Contains("v5")) { throw "V-D5x FAIL: HUD does not report v5 (PROTO_LABEL out of sync?): [$hud]" }
|
||||||
|
$m = [regex]::Match($hud, '(\d+)x(\d+)')
|
||||||
|
if (-not $m.Success) { throw "cannot parse desktop size from HUD: [$hud]" }
|
||||||
|
$dW = [int]$m.Groups[1].Value; $dH = [int]$m.Groups[2].Value
|
||||||
|
Write-Host " desktop from HUD: ${dW}x${dH}"
|
||||||
|
|
||||||
|
# Marker geometry works in EITHER orientation (letterbox mapping is proportional),
|
||||||
|
# but landscape is the 4a headline case. AUTO rotates for a wide desktop - give the
|
||||||
|
# rotation a moment to land, then proceed on whatever orientation is actually up.
|
||||||
|
$rot = (Get-Date).AddSeconds(3); $d = LocalDisplay
|
||||||
|
while ((Get-Date) -lt $rot) {
|
||||||
|
$d = LocalDisplay
|
||||||
|
if ($d.w -gt $d.h) { break }
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
Write-Host (" orientation: {0}x{1} ({2})" -f $d.w, $d.h, $(if ($d.w -gt $d.h) { "landscape" } else { "portrait" }))
|
||||||
|
Start-Sleep -Seconds 2 # let the hide timer run so the video fills its final rect
|
||||||
|
|
||||||
|
$ui = Get-UiRetry
|
||||||
|
$vb = VideoBounds $ui
|
||||||
|
if (-not $vb) { throw "video (16:9 View) not found in the tree - every coordinate expectation below would be meaningless (N21)" }
|
||||||
|
Write-Host (" video bounds: l={0} t={1} w={2} h={3} ratio err {4:N4}" -f $vb.l, $vb.t, $vb.w, $vb.h, $vb.err)
|
||||||
|
if ($vb.err -gt 0.02) { throw "video not 16:9 - geometry changed since 4a, re-derive the mapping here" }
|
||||||
|
|
||||||
|
function Expect-OnScreen([int]$cx, [int]$cy) {
|
||||||
|
# CursorMarker on-screen anchor = video view origin + mapped point.
|
||||||
|
# PS parses `a / [double]b, c` inside @() as an array construction (the type
|
||||||
|
# literal + comma is a known pitfall - measured 2026-11-02: this threw
|
||||||
|
# op_Division on Object[] / silently produced 3 elements). Parenthesize each
|
||||||
|
# element so each division is evaluated as a scalar first.
|
||||||
|
@( ($vb.l + $cx * $vb.w / [double]$dW), ($vb.t + $cy * $vb.h / [double]$dH) )
|
||||||
|
}
|
||||||
|
function Check-MarkerAt([int]$cx, [int]$cy, [string]$tag, [double]$tol = 6.0) {
|
||||||
|
Set-CursorQuiet $cx $cy
|
||||||
|
Start-Sleep -Milliseconds 700 # >= one 500ms idle frame + render
|
||||||
|
$png = Take-Shot $tag
|
||||||
|
$mk = Find-Marker $png
|
||||||
|
if (-not $mk) { throw "V-D1 FAIL [$tag]: no marker at desktop ($cx,$cy) - cyan cluster absent" }
|
||||||
|
$e = Expect-OnScreen $cx $cy
|
||||||
|
$dx = [Math]::Abs($mk.x - $e[0]); $dy = [Math]::Abs($mk.y - $e[1])
|
||||||
|
Write-Host (" [{0}] marker ({1:N0},{2:N0}) n={3} spread={4} expect ({5:N0},{6:N0}) dev=({7:N1},{8:N1})" -f `
|
||||||
|
$tag, $mk.x, $mk.y, $mk.n, $mk.spread, $e[0], $e[1], $dx, $dy)
|
||||||
|
if ($dx -gt $tol -or $dy -gt $tol) { throw "V-D1 FAIL [$tag]: deviation ($dx,$dy) > $tol px (shot: $png)" }
|
||||||
|
if ($mk.spread -gt 90) { throw "V-D1 FAIL [$tag]: cyan spread $($mk.spread)px - too wide to be the marker dot (false positive path)" }
|
||||||
|
return $mk
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 2. V-D1: four desktop points (incl. near-origin) ==="
|
||||||
|
# (48,48) instead of (0,0): the dot radius would clip at the exact corner and
|
||||||
|
# bias the centroid; Some(0,0)-vs-None semantics are already locked by unit +
|
||||||
|
# golden tests. Corner-proximity error is bounded well below tol here.
|
||||||
|
Check-MarkerAt 48 48 "v-d1-origin" | Out-Null
|
||||||
|
Check-MarkerAt 1280 720 "v-d1-center" | Out-Null
|
||||||
|
Check-MarkerAt 2400 1300 "v-d1-lowright" | Out-Null
|
||||||
|
Check-MarkerAt 2000 300 "v-d1-upperright" | Out-Null
|
||||||
|
Write-Host " PASS: marker centroid within 6 px for all 4 points"
|
||||||
|
|
||||||
|
Write-Host "=== 3. V-D3: idle-desktop move refreshes the marker <= ~1.2s ==="
|
||||||
|
# Quiet gate, then one decisive jump; 700ms above already covers 500ms resend.
|
||||||
|
$from = Check-MarkerAt 300 1200 "v-d3-from"
|
||||||
|
$e2 = Expect-OnScreen 1600 400
|
||||||
|
Set-CursorQuiet 1600 400
|
||||||
|
Start-Sleep -Milliseconds 900
|
||||||
|
$mk3 = Find-Marker (Take-Shot "v-d3-to")
|
||||||
|
if (-not $mk3) { throw "V-D3 FAIL: marker vanished after moving the cursor on a quiet desktop" }
|
||||||
|
$dx3 = [Math]::Abs($mk3.x - $e2[0]); $dy3 = [Math]::Abs($mk3.y - $e2[1])
|
||||||
|
Write-Host (" after jump: marker ({0:N0},{1:N0}) expect ({2:N0},{3:N0}) dev=({4:N1},{5:N1})" -f `
|
||||||
|
$mk3.x, $mk3.y, $e2[0], $e2[1], $dx3, $dy3)
|
||||||
|
if ($dx3 -gt 6 -or $dy3 -gt 6) { throw "V-D3 FAIL: marker did not follow within 900ms (stale cursor field?)" }
|
||||||
|
Write-Host " PASS: marker refreshes within one idle-resend window"
|
||||||
|
|
||||||
|
Write-Host "=== 4. V-D7: cursor into the LEFT second monitor folds to None ==="
|
||||||
|
$vd = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
||||||
|
if ($vd.X -ge 0) {
|
||||||
|
Write-Host " SKIP: no negative-X monitor on this machine - V-D7 (off-screen fold) cannot be tested here; the fold rule is locked by fold_cursor unit tests"
|
||||||
|
} else {
|
||||||
|
$ox = [int]($vd.X + 100) # deep into the second display (negative X)
|
||||||
|
$oy = 500
|
||||||
|
Set-CursorQuiet $ox $oy
|
||||||
|
Start-Sleep -Milliseconds 900
|
||||||
|
$mk7 = Find-Marker (Take-Shot "v-d7-second-monitor")
|
||||||
|
if ($mk7) { throw "V-D7 FAIL: marker still drawn with cursor at ($ox,$oy) outside the captured display (n=$($mk7.n) at {x=$($mk7.x);y=$($mk7.y)})" }
|
||||||
|
Write-Host " PASS: no cyan cluster with the pointer at ($ox,$oy) - folded to None end to end"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 5. tidy: park the pointer inside the primary screen ==="
|
||||||
|
Set-CursorQuiet ([int]($dW/2)) ([int]($dH/2))
|
||||||
|
Write-Host "=== CURSOR PROOF COMPLETE ==="
|
||||||
197
tools/emoji_manual_proof.ps1
Normal file
197
tools/emoji_manual_proof.ps1
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
# 2b V7 (device side): manual emoji acceptance helper.
|
||||||
|
# ASCII-only source (PS 5.1 + BOM-less = GBK corruption; see personal lessons).
|
||||||
|
#
|
||||||
|
# WHY MANUAL: the IME's own emoji panel is invisible to uiautomator (dump shows
|
||||||
|
# only our app's nodes), so adb cannot drive emoji selection. The human taps the
|
||||||
|
# emoji; this script only prepares the receiving window and reads back what
|
||||||
|
# actually arrived - the judgement is still made against real received bytes.
|
||||||
|
#
|
||||||
|
# What this proves: the phone IME commits a supplementary-plane character
|
||||||
|
# (surrogate pair) as ONE commitText, and protocol v3 delivers both code units
|
||||||
|
# adjacently so Windows synthesises a single character.
|
||||||
|
# The Windows half is already proven mechanically (V7' in text_injection_proof).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File tools\emoji_manual_proof.ps1
|
||||||
|
# ... then, when prompted, tap an emoji on the phone's keyboard.
|
||||||
|
|
||||||
|
param(
|
||||||
|
# PC (controlled side) LAN address; DHCP-assigned - pass -PhoneIp when it
|
||||||
|
# moves (see ime_chinese_proof.ps1 for the 2026-10-31 stale-default lesson).
|
||||||
|
[string]$PhoneIp = "192.168.1.110",
|
||||||
|
[int]$Port = 47800,
|
||||||
|
[string]$Code = "t",
|
||||||
|
[int]$WaitSeconds = 120
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = (Resolve-Path "$PSScriptRoot\..").Path
|
||||||
|
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||||
|
# UNIQUE per run: a fixed path lets the startup Remove-Item below delete the
|
||||||
|
# stop.flag that leftover targets from a previous FAILED run are watching, so
|
||||||
|
# they never retire and keep writing into the shared received.txt
|
||||||
|
# (same trap fixed in phone_chain_proof, measured 2026-11-01).
|
||||||
|
$tmp = "$env:TEMP\remote-emoji-proof-$PID"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||||
|
$pidFile = "$tmp\target.pid"
|
||||||
|
$stopFile = "$tmp\stop.flag"
|
||||||
|
$resultFile = "$tmp\received.txt"
|
||||||
|
Remove-Item $pidFile,$stopFile,$resultFile -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class FgEmoji {
|
||||||
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
|
||||||
|
function Adb-Shell {
|
||||||
|
param([Parameter(Mandatory)][string[]]$Cmd)
|
||||||
|
$old = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
$out = & $adb shell @Cmd 2>$null
|
||||||
|
return @{ out = ($out -join "`n"); code = $LASTEXITCODE }
|
||||||
|
}
|
||||||
|
finally { $ErrorActionPreference = $old }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-File {
|
||||||
|
param([string]$Path, [int]$TimeoutMs = 20000)
|
||||||
|
$t = 0
|
||||||
|
while (-not (Test-Path $Path) -and $t -lt ($TimeoutMs / 150)) { Start-Sleep -Milliseconds 150; $t++ }
|
||||||
|
if (-not (Test-Path $Path)) { throw "timeout waiting for $Path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-Received {
|
||||||
|
if (-not (Test-Path $resultFile)) { return "" }
|
||||||
|
$b = [System.IO.File]::ReadAllBytes($resultFile)
|
||||||
|
$s = [System.Text.Encoding]::UTF8.GetString($b)
|
||||||
|
if ($s.Length -gt 0 -and $s[0] -eq [char]0xFEFF) { $s = $s.Substring(1) }
|
||||||
|
return $s
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Cps([string]$s) {
|
||||||
|
($s.ToCharArray() | ForEach-Object { "U+{0:X4}" -f [int]$_ }) -join " "
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 1. retire earlier targets, ensure fresh agent ==="
|
||||||
|
$flags = @(Get-ChildItem -Path $env:TEMP -Recurse -Filter "stop.flag" -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.FullName -like "*remote*proof*" })
|
||||||
|
foreach ($f in $flags) { (Get-Item $f.FullName).LastWriteTime = Get-Date }
|
||||||
|
if ($flags.Count -gt 0) { Start-Sleep -Seconds 3 }
|
||||||
|
|
||||||
|
$agentExe = "$repo\target\release\agent.exe"
|
||||||
|
$ag = Get-Process agent -ErrorAction SilentlyContinue
|
||||||
|
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
|
||||||
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 800; $ag = $null
|
||||||
|
}
|
||||||
|
if (-not $ag) {
|
||||||
|
Start-Process $agentExe -ArgumentList "--port",$Port,"--code",$Code -WindowStyle Hidden
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
Write-Host " agent started"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 2. target window (receives the injected text) ==="
|
||||||
|
Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass",`
|
||||||
|
"-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,`
|
||||||
|
"-ResultFile",$resultFile,"-StopFile",$stopFile,"-GrabForeground"
|
||||||
|
Wait-File $pidFile
|
||||||
|
$parts = @((Get-Content $pidFile -Raw) -split "`n")
|
||||||
|
$tid = [int]$parts[0].Trim()
|
||||||
|
$hwnd = [IntPtr][long]$parts[1].Trim()
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
$fg = [FgEmoji]::GetForegroundWindow()
|
||||||
|
Write-Host " target pid=$tid hwnd=$($hwnd.ToInt64()) foreground=$($fg -eq $hwnd)"
|
||||||
|
|
||||||
|
Write-Host "=== 3. phone session + show keyboard ==="
|
||||||
|
[void](Adb-Shell @("am","force-stop","com.verify.remote"))
|
||||||
|
[void](Adb-Shell @("am","start","-n","com.verify.remote/.MainActivity",
|
||||||
|
"--es","host",$PhoneIp,"--ei","port","$Port","--es","code",$Code))
|
||||||
|
$deadline = (Get-Date).AddSeconds(25)
|
||||||
|
$ui = ""
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 900
|
||||||
|
[void](Adb-Shell @("uiautomator","dump","/sdcard/ui.xml"))
|
||||||
|
$old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
||||||
|
try { & $adb pull /sdcard/ui.xml "$tmp\ui.xml" 2>$null | Out-Null } finally { $ErrorActionPreference = $old }
|
||||||
|
if (Test-Path "$tmp\ui.xml") { $ui = [System.IO.File]::ReadAllText("$tmp\ui.xml", [System.Text.Encoding]::UTF8) }
|
||||||
|
if ($ui -match "RTT=") { break }
|
||||||
|
}
|
||||||
|
if ($ui -notmatch "RTT=") { throw "phone did not connect" }
|
||||||
|
|
||||||
|
$btns = [regex]::Matches($ui, '<node[^>]*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if ($btns.Count -lt 1) { throw "keyboard button not found" }
|
||||||
|
$b0 = $btns[0]
|
||||||
|
$kx = [int](([int]$b0.Groups[1].Value + [int]$b0.Groups[3].Value) / 2)
|
||||||
|
$ky = [int](([int]$b0.Groups[2].Value + [int]$b0.Groups[4].Value) / 2)
|
||||||
|
[void](Adb-Shell @("input","tap","$kx","$ky"))
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
$ime = (Adb-Shell @("dumpsys","input_method")).out
|
||||||
|
if ($ime -notmatch "mInputShown=true") { throw "IME not shown" }
|
||||||
|
if ($ime -notmatch "mHaveConnection=true") { throw "IME not bound to our InputConnection" }
|
||||||
|
Write-Host " IME shown and bound: $([regex]::Match($ime,'mCurId=(\S+)').Groups[1].Value)"
|
||||||
|
|
||||||
|
$baseline = Read-Received
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " ------------------------------------------------------------"
|
||||||
|
Write-Host " NOW ON THE PHONE: tap the emoji key, then tap ONE emoji."
|
||||||
|
Write-Host " (Keyboard is up; the Windows target window is receiving.)"
|
||||||
|
Write-Host " Waiting up to $WaitSeconds s for a supplementary-plane character..."
|
||||||
|
Write-Host " ------------------------------------------------------------"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$deadline = (Get-Date).AddSeconds($WaitSeconds)
|
||||||
|
$got = ""
|
||||||
|
$found = $false
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$got = Read-Received
|
||||||
|
if ($got.Length -gt $baseline.Length) {
|
||||||
|
$new = $got.Substring($baseline.Length)
|
||||||
|
# any code unit in the surrogate range means an emoji (or other
|
||||||
|
# supplementary-plane char) reached us
|
||||||
|
foreach ($ch in $new.ToCharArray()) {
|
||||||
|
$cp = [int]$ch
|
||||||
|
if ($cp -ge 0xD800 -and $cp -le 0xDFFF) { $found = $true; break }
|
||||||
|
}
|
||||||
|
if ($found) { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$final = Read-Received
|
||||||
|
$newFinal = if ($final.Length -ge $baseline.Length) { $final.Substring($baseline.Length) } else { "" }
|
||||||
|
Write-Host "=== 4. result ==="
|
||||||
|
Write-Host " baseline : [$(Show-Cps $baseline)]"
|
||||||
|
Write-Host " final : [$(Show-Cps $final)]"
|
||||||
|
Write-Host " new chars: [$(Show-Cps $newFinal)]"
|
||||||
|
Write-Host " new units: $($newFinal.Length)"
|
||||||
|
|
||||||
|
if ($found) {
|
||||||
|
# verify the pair is adjacent and forms a real code point
|
||||||
|
$units = $newFinal.ToCharArray() | ForEach-Object { [int]$_ }
|
||||||
|
$paired = $false
|
||||||
|
for ($i = 0; $i -lt $units.Count - 1; $i++) {
|
||||||
|
if ($units[$i] -ge 0xD800 -and $units[$i] -le 0xDBFF -and
|
||||||
|
$units[$i+1] -ge 0xDC00 -and $units[$i+1] -le 0xDFFF) {
|
||||||
|
$cp = 0x10000 + (($units[$i] - 0xD800) * 0x400) + ($units[$i+1] - 0xDC00)
|
||||||
|
Write-Host (" V7 PASS: adjacent surrogate pair -> U+{0:X4} ({1} code units committed as one)" -f $cp, $newFinal.Length)
|
||||||
|
$paired = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $paired) { throw "V7 FAIL: surrogate units present but not an adjacent valid pair" }
|
||||||
|
if ($newFinal.Contains([char]0xFFFD)) { throw "V7 FAIL: U+FFFD present (pair was split)" }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " V7 INCONCLUSIVE: no supplementary-plane character arrived within $WaitSeconds s"
|
||||||
|
Write-Host " (If you tapped an emoji and nothing arrived, that IS the finding - record it.)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 5. cleanup ==="
|
||||||
|
[void](Adb-Shell @("input","keyevent","4"))
|
||||||
|
New-Item -ItemType File -Force -Path $stopFile | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
Write-Host "=== done ==="
|
||||||
302
tools/ime_chinese_proof.ps1
Normal file
302
tools/ime_chinese_proof.ps1
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
# 2b V6 on-device CHINESE: drive the IME through pinyin + candidate selection.
|
||||||
|
# ASCII-only source. Non-ASCII expectations are built from code points.
|
||||||
|
#
|
||||||
|
# Measured path differences (2026-10-30, vivo S6 + iFlytek IME):
|
||||||
|
# - `adb input keyevent <letter>` behaves like a HARDWARE keyboard: the IME
|
||||||
|
# does not compose, the event lands in Activity.onKeyDown and our printable
|
||||||
|
# unicodeChar branch sends it as text. So keyevents CANNOT exercise Chinese
|
||||||
|
# candidate selection.
|
||||||
|
# - `adb input text <latin>` is delivered to the IME, which composes it;
|
||||||
|
# SPACE (keyevent 62) then accepts the highlighted candidate, and the Hanzi
|
||||||
|
# arrive at RemoteInputConnection.commitText.
|
||||||
|
# This script therefore types pinyin with `input text` and commits with SPACE.
|
||||||
|
#
|
||||||
|
# Acceptance: the Windows target window receives the exact Han characters the
|
||||||
|
# IME committed - Chinese input works end to end over protocol v3.
|
||||||
|
# Not covered: swipe/handwriting input, browsing the candidate list.
|
||||||
|
|
||||||
|
param(
|
||||||
|
# PC (controlled side) LAN address. DHCP-assigned and DRIFTED twice
|
||||||
|
# (.180 -> .110 -> .112); a stale default silently dials a dead host
|
||||||
|
# (measured 2026-11-01: this run failed "phone did not connect" because the
|
||||||
|
# caller passed -Host_, which does NOT bind to this parameter, so the
|
||||||
|
# default was used). Always pass -PhoneIp explicitly and check the real IP.
|
||||||
|
[string]$PhoneIp = "192.168.1.112",
|
||||||
|
[int]$Port = 47800,
|
||||||
|
[string]$Code = "t"
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = (Resolve-Path "$PSScriptRoot\..").Path
|
||||||
|
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||||
|
# UNIQUE per-run dir: with a fixed path, the startup Remove-Item deletes the
|
||||||
|
# stop.flag that leftover targets from a previous failed run are watching, so
|
||||||
|
# they can never retire and they pollute the shared received.txt
|
||||||
|
# (same class of bug measured 2026-11-01 in phone_chain_proof).
|
||||||
|
$tmp = "$env:TEMP\remote-cjk2-$PID"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||||
|
$pidFile = "$tmp\target.pid"
|
||||||
|
$stopFile = "$tmp\stop.flag"
|
||||||
|
$resultFile = "$tmp\received.txt"
|
||||||
|
Remove-Item $pidFile,$stopFile,$resultFile -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
public static class Fg5 {
|
||||||
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||||
|
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||||
|
[DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
|
||||||
|
public static string Desc() {
|
||||||
|
IntPtr h = GetForegroundWindow(); uint pid; GetWindowThreadProcessId(h, out pid);
|
||||||
|
var sb = new StringBuilder(256); GetWindowText(h, sb, 256);
|
||||||
|
return h.ToInt64() + " [" + sb.ToString() + "] pid=" + pid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
|
||||||
|
function Adb-Shell {
|
||||||
|
param([Parameter(Mandatory)][string[]]$Cmd)
|
||||||
|
$old = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
$out = & $adb shell @Cmd 2>$null
|
||||||
|
return @{ out = ($out -join "`n"); code = $LASTEXITCODE }
|
||||||
|
}
|
||||||
|
finally { $ErrorActionPreference = $old }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-File {
|
||||||
|
param([string]$Path, [int]$TimeoutMs = 20000)
|
||||||
|
$t = 0
|
||||||
|
while (-not (Test-Path $Path) -and $t -lt ($TimeoutMs / 150)) { Start-Sleep -Milliseconds 150; $t++ }
|
||||||
|
if (-not (Test-Path $Path)) { throw "timeout waiting for $Path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Foreground([IntPtr]$hwnd, [string]$where) {
|
||||||
|
$deadline = (Get-Date).AddMilliseconds(2500)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
if ([Fg5]::GetForegroundWindow() -eq $hwnd) { return }
|
||||||
|
Start-Sleep -Milliseconds 50
|
||||||
|
}
|
||||||
|
throw "$where : foreground is $([Fg5]::Desc()), want target $($hwnd.ToInt64()) - refusing to inject"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Ui {
|
||||||
|
[void](Adb-Shell @("uiautomator", "dump", "/sdcard/ui.xml"))
|
||||||
|
$old = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try { & $adb pull /sdcard/ui.xml "$tmp\ui.xml" 2>$null | Out-Null }
|
||||||
|
finally { $ErrorActionPreference = $old }
|
||||||
|
if (-not (Test-Path "$tmp\ui.xml")) { return "" }
|
||||||
|
return [System.IO.File]::ReadAllText("$tmp\ui.xml", [System.Text.Encoding]::UTF8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-Received {
|
||||||
|
if (-not (Test-Path $resultFile)) { return "" }
|
||||||
|
$b = [System.IO.File]::ReadAllBytes($resultFile)
|
||||||
|
$s = [System.Text.Encoding]::UTF8.GetString($b)
|
||||||
|
if ($s.Length -gt 0 -and $s[0] -eq [char]0xFEFF) { $s = $s.Substring(1) }
|
||||||
|
return $s
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Cps([string]$s) {
|
||||||
|
($s.ToCharArray() | ForEach-Object { "U+{0:X4}" -f [int]$_ }) -join " "
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 4a landscape adaptations ----
|
||||||
|
# In landscape the HUD TextView hides WITH the auto-hiding control bar, so a
|
||||||
|
# plain "RTT=" read returns nothing while the session is alive, and the
|
||||||
|
# keyboard button is not in the tree at all. Both reads must wake through the
|
||||||
|
# letterbox black bar first (that tap is consumed by the app; it never reaches
|
||||||
|
# the remote). NB: this script's Adb-Shell returns @{out;code}, not a string.
|
||||||
|
function Phone-DisplaySize {
|
||||||
|
$d = (Adb-Shell @("dumpsys", "window", "displays")).out
|
||||||
|
$idx = $d.IndexOf("Display: mDisplayId=0")
|
||||||
|
if ($idx -lt 0) { return @{ w = 1080; h = 2400 } }
|
||||||
|
$m = [regex]::Match($d.Substring($idx), 'cur=(\d+)x(\d+)')
|
||||||
|
if (-not $m.Success) { return @{ w = 1080; h = 2400 } }
|
||||||
|
return @{ w = [int]$m.Groups[1].Value; h = [int]$m.Groups[2].Value }
|
||||||
|
}
|
||||||
|
function Wake-Controls-If-Hidden([string]$xml) {
|
||||||
|
$hidden = ($xml -match 'class="android\.view\.View"') -and ($xml -notmatch 'class="android\.widget\.Button"')
|
||||||
|
if (-not $hidden) { return $false }
|
||||||
|
$pd = Phone-DisplaySize
|
||||||
|
[void](Adb-Shell @("input", "tap", "100", "$([int]($pd.h / 2))"))
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
# Read the tree until it shows a live session, waking the controls as needed.
|
||||||
|
function Get-Ui-Live([int]$Seconds) {
|
||||||
|
$deadline = (Get-Date).AddSeconds($Seconds)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 900
|
||||||
|
$x = Get-Ui
|
||||||
|
if ($x -match "RTT=") { return $x }
|
||||||
|
if (Wake-Controls-If-Hidden $x) {
|
||||||
|
$x = Get-Ui
|
||||||
|
if ($x -match "RTT=") { return $x }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (Get-Ui)
|
||||||
|
}
|
||||||
|
# Find a Button by label prefix in a tree that may have auto-hidden controls.
|
||||||
|
function Find-ButtonByPrefix([string]$xml, [string]$prefix) {
|
||||||
|
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||||
|
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||||
|
if ($t.StartsWith($prefix)) {
|
||||||
|
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if ($b.Success) {
|
||||||
|
return @(
|
||||||
|
[int]((([int]$b.Groups[1].Value) + ([int]$b.Groups[3].Value)) / 2),
|
||||||
|
[int]((([int]$b.Groups[2].Value) + ([int]$b.Groups[4].Value)) / 2),
|
||||||
|
$t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
function Find-ButtonByPrefix-Live([string]$prefix) {
|
||||||
|
for ($t=0; $t -lt 4; $t++) {
|
||||||
|
$x = Get-Ui
|
||||||
|
$b = Find-ButtonByPrefix $x $prefix
|
||||||
|
if ($b) { return $b }
|
||||||
|
if (-not (Wake-Controls-If-Hidden $x)) { Start-Sleep -Milliseconds 400 }
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
# Force PORTRAIT for this proof. Why: the IME's ab/zh language key is NOT visible
|
||||||
|
# to uiautomator (its key views are separate windows), so the toggle is reached by
|
||||||
|
# a CALIBRATED tap that is portrait-specific (146,2096 on 1080x2400). In landscape
|
||||||
|
# that y is off-screen, so the retry path could not work. The text pipeline itself
|
||||||
|
# is orientation-independent (landscape 'abc' is already covered by
|
||||||
|
# phone_chain_proof), so running V6 in portrait costs nothing in coverage.
|
||||||
|
function Set-Orientation-Portrait {
|
||||||
|
$F_ORIENT = -join ([char]0x65B9,[char]0x5411,[char]0x3A) # "fangxiang:"
|
||||||
|
$F_PORTRAIT = $F_ORIENT + -join ([char]0x7AD6,[char]0x5C4F) # "...:portrait"
|
||||||
|
for ($t=0; $t -lt 8; $t++) {
|
||||||
|
$b = Find-ButtonByPrefix-Live $F_ORIENT
|
||||||
|
if (-not $b) { throw "orientation button not reachable" }
|
||||||
|
if ($b[2] -eq $F_PORTRAIT) { Write-Host " orientation: already portrait"; return }
|
||||||
|
[void](Adb-Shell @("input","tap","$($b[0])","$($b[1])"))
|
||||||
|
Start-Sleep -Milliseconds 1300
|
||||||
|
}
|
||||||
|
throw "could not reach portrait orientation after 8 taps"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 0. retire ALL earlier targets, verify none holds foreground ==="
|
||||||
|
$flags = @(Get-ChildItem -Path $env:TEMP -Recurse -Filter "stop.flag" -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.FullName -like "*remote*cjk*" -or $_.FullName -like "*remote*proof*" })
|
||||||
|
foreach ($f in $flags) { (Get-Item $f.FullName).LastWriteTime = Get-Date }
|
||||||
|
if ($flags.Count -gt 0) { Start-Sleep -Seconds 3 }
|
||||||
|
$hosts = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" |
|
||||||
|
Where-Object { $_.CommandLine -match 'text_target\.ps1' -and $_.ProcessId -ne $PID })
|
||||||
|
foreach ($hx in $hosts) { Stop-Process -Id $hx.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||||
|
if ($hosts.Count -gt 0) { Start-Sleep -Seconds 1 }
|
||||||
|
Write-Host " retired $($flags.Count) flag(s), killed $($hosts.Count) host(s); fg now: $([Fg5]::Desc())"
|
||||||
|
|
||||||
|
Write-Host "=== 1. agent freshness ==="
|
||||||
|
$agentExe = "$repo\target\release\agent.exe"
|
||||||
|
$ag = Get-Process agent -ErrorAction SilentlyContinue
|
||||||
|
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
|
||||||
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 800; $ag = $null
|
||||||
|
}
|
||||||
|
if (-not $ag) {
|
||||||
|
Start-Process $agentExe -ArgumentList "--port",$Port,"--code",$Code -WindowStyle Hidden
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 2. fresh target window (empty field) ==="
|
||||||
|
Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass",`
|
||||||
|
"-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,`
|
||||||
|
"-ResultFile",$resultFile,"-StopFile",$stopFile,"-GrabForeground"
|
||||||
|
Wait-File $pidFile
|
||||||
|
$parts = @((Get-Content $pidFile -Raw) -split "`n")
|
||||||
|
$tid = [int]$parts[0].Trim()
|
||||||
|
$hwnd = [IntPtr][long]$parts[1].Trim()
|
||||||
|
Assert-Foreground $hwnd "after launch"
|
||||||
|
$base0 = Read-Received
|
||||||
|
Write-Host " target pid=$tid hwnd=$($hwnd.ToInt64()) baseline=[$(Show-Cps $base0)]"
|
||||||
|
|
||||||
|
Write-Host "=== 3. phone session ==="
|
||||||
|
[void](Adb-Shell @("am","force-stop","com.verify.remote"))
|
||||||
|
[void](Adb-Shell @("am","start","-n","com.verify.remote/.MainActivity",
|
||||||
|
"--es","host",$PhoneIp,"--ei","port","$Port","--es","code",$Code))
|
||||||
|
$ui = Get-Ui-Live 25
|
||||||
|
if ($ui -notmatch "RTT=") { throw "phone did not connect" }
|
||||||
|
Write-Host " live: $([regex]::Match($ui,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
|
||||||
|
# 4a: pin PORTRAIT before driving the IME (calibrated toggle tap is portrait-only)
|
||||||
|
Set-Orientation-Portrait
|
||||||
|
|
||||||
|
Write-Host "=== 4. show IME (tap keyboard button) ==="
|
||||||
|
# Select the keyboard button BY ITS LABEL, not by position.
|
||||||
|
# 3b added a sensitivity row (- / +) above the button bar, so "the first Button"
|
||||||
|
# is now the minus sign - the old positional assumption silently tapped the wrong
|
||||||
|
# control and the proof reported "IME not shown" (a harness defect, not a product
|
||||||
|
# regression; measured 2026-10-31). Label matching is order-independent.
|
||||||
|
$F_KEYBOARD = -join ([char]0x952E,[char]0x76D8) # "keyboard"
|
||||||
|
$kb = Find-ButtonByPrefix-Live $F_KEYBOARD
|
||||||
|
if (-not $kb) { throw "keyboard button not found by label" }
|
||||||
|
[void](Adb-Shell @("input","tap","$($kb[0])","$($kb[1])"))
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
$ime = (Adb-Shell @("dumpsys","input_method")).out
|
||||||
|
if ($ime -notmatch "mInputShown=true") { throw "IME not shown" }
|
||||||
|
if ($ime -notmatch "mHaveConnection=true") { throw "IME not bound to our InputConnection" }
|
||||||
|
Write-Host " IME: $([regex]::Match($ime,'mCurId=(\S+)').Groups[1].Value)"
|
||||||
|
|
||||||
|
Write-Host "=== 4b/5. pinyin -> IME candidate -> commitText (mode-tolerant) ==="
|
||||||
|
# The IME may remember an ENGLISH mode, in which case letters pass through
|
||||||
|
# without composing - which looks identical to "our pipeline lost the Hanzi"
|
||||||
|
# unless you check. The IME's key views are NOT visible to uiautomator (its
|
||||||
|
# dump contains only our app's nodes), so the zh/en toggle is reached by a
|
||||||
|
# calibrated tap. A stray tap here changes a keyboard setting, nothing
|
||||||
|
# destructive. The loop below is idempotent by design (R-9): flip only if
|
||||||
|
# letters-without-Hanzi were seen, and the judgement is the received text.
|
||||||
|
#
|
||||||
|
# Toggle calibration (2026-11-01): the device IME is now SOGOU
|
||||||
|
# (com.sohu.inputmethod.sogou.vivo), not iFlytek - the old (146,2096) iFlytek
|
||||||
|
# coordinate silently missed the key and both attempts stayed in English mode.
|
||||||
|
# Re-measured from screencap: bottom row, the language key right of '.'
|
||||||
|
# (labelled zh/en) at device px (838,2282) on 1080x2400 portrait.
|
||||||
|
$modeToggle = @(838, 2282)
|
||||||
|
$expect = -join @([char]0x4F60, [char]0x597D) # NI HAO
|
||||||
|
$attempt = 0
|
||||||
|
$new = ""
|
||||||
|
while ($attempt -lt 2) {
|
||||||
|
$attempt++
|
||||||
|
$base = Read-Received
|
||||||
|
Assert-Foreground $hwnd "attempt ${attempt}: before pinyin"
|
||||||
|
[void](Adb-Shell @("input","text","nihao"))
|
||||||
|
Start-Sleep -Milliseconds 1500
|
||||||
|
$mid = Read-Received
|
||||||
|
Write-Host " attempt ${attempt} after pinyin (composing): [$(Show-Cps $mid)]"
|
||||||
|
Assert-Foreground $hwnd "attempt ${attempt}: before SPACE"
|
||||||
|
[void](Adb-Shell @("input","keyevent","62"))
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$got = Read-Received
|
||||||
|
$new = if ($got.Length -ge $base.Length) { $got.Substring($base.Length) } else { "" }
|
||||||
|
Write-Host " attempt ${attempt} newly received: [$(Show-Cps $new)]"
|
||||||
|
if ($new -match [regex]::Escape($expect)) { break }
|
||||||
|
if ($attempt -eq 1) {
|
||||||
|
Write-Host " no Hanzi yet -> flipping IME language mode and retrying once"
|
||||||
|
[void](Adb-Shell @("input","tap","$($modeToggle[0])","$($modeToggle[1])"))
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$diag = [regex]::Match((Get-Ui), 'text="([^"]*RTT=[^"]*)"').Groups[1].Value
|
||||||
|
Write-Host " phone status: $diag"
|
||||||
|
Write-Host " expected to contain: $(Show-Cps $expect)"
|
||||||
|
if ($new -notmatch [regex]::Escape($expect)) {
|
||||||
|
Write-Host " GROUND TRUTH: [$(Show-Cps $new)]"
|
||||||
|
Write-Host " U+006E.. letters only => IME stayed in English mode (re-calibrate the toggle tap)"
|
||||||
|
throw "V6 CHINESE FAIL: IME did not deliver Hanzi through commitText"
|
||||||
|
}
|
||||||
|
Write-Host " V6 PASS: pinyin -> IME candidate -> commitText -> v3 Text -> Windows Hanzi"
|
||||||
|
|
||||||
|
Write-Host "=== 6. cleanup ==="
|
||||||
|
[void](Adb-Shell @("input","keyevent","4"))
|
||||||
|
New-Item -ItemType File -Force -Path $stopFile | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
Write-Host "=== CHINESE IME PROOF COMPLETE ==="
|
||||||
188
tools/ime_commit_proof.ps1
Normal file
188
tools/ime_commit_proof.ps1
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
# 2b V6/V7 on-device IME path: does a real IME's commitText reach the remote
|
||||||
|
# desktop? ASCII-only source (see personal lessons on PS 5.1 + BOM-less .ps1).
|
||||||
|
#
|
||||||
|
# Difference from phone_chain_proof.ps1: that one drives Activity.onKeyDown with
|
||||||
|
# adb keyevents (IME hidden). Here the IME is SHOWN, so keys are consumed by the
|
||||||
|
# input method and reach us only through RemoteInputConnection.commitText /
|
||||||
|
# sendKeyEvent. That is the path Chinese candidates take, and it is the risk R1
|
||||||
|
# item in the plan (vivo ROM + third-party IME behaviour was never observed).
|
||||||
|
#
|
||||||
|
# What this proves mechanically: IME is bound to our InputConnection and its
|
||||||
|
# committed text lands in a Windows window we own, via protocol v3.
|
||||||
|
# What it cannot prove: human candidate-list selection (needs a finger); see
|
||||||
|
# the result record - V6/V7 full judgement is listed as manual acceptance.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File tools\ime_commit_proof.ps1
|
||||||
|
|
||||||
|
param(
|
||||||
|
# PC (controlled side) LAN address; DHCP-assigned - pass -PhoneIp when it
|
||||||
|
# moves (see ime_chinese_proof.ps1 for the 2026-10-31 stale-default lesson).
|
||||||
|
[string]$PhoneIp = "192.168.1.110",
|
||||||
|
[int]$Port = 47800,
|
||||||
|
[string]$Code = "t",
|
||||||
|
[string]$Text = "hi"
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = (Resolve-Path "$PSScriptRoot\..").Path
|
||||||
|
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||||
|
# UNIQUE per run: a fixed path lets the startup Remove-Item below delete the
|
||||||
|
# stop.flag that leftover targets from a previous FAILED run are watching, so
|
||||||
|
# they never retire and keep writing into the shared received.txt
|
||||||
|
# (same trap fixed in phone_chain_proof, measured 2026-11-01).
|
||||||
|
$tmp = "$env:TEMP\remote-ime-proof-$PID"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||||
|
$pidFile = "$tmp\target.pid"
|
||||||
|
$stopFile = "$tmp\stop.flag"
|
||||||
|
$resultFile = "$tmp\received.txt"
|
||||||
|
Remove-Item $pidFile,$stopFile,$resultFile -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class FgApi2 {
|
||||||
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
|
||||||
|
function Adb {
|
||||||
|
param([Parameter(Mandatory)][string[]]$AdbArgs)
|
||||||
|
$old = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
$out = & $adb @AdbArgs 2>$null
|
||||||
|
return @{ out = ($out -join "`n"); code = $LASTEXITCODE }
|
||||||
|
}
|
||||||
|
finally { $ErrorActionPreference = $old }
|
||||||
|
}
|
||||||
|
function Adb-Shell { param([Parameter(Mandatory)][string[]]$Cmd) return Adb -AdbArgs (@("shell") + $Cmd) }
|
||||||
|
|
||||||
|
function Wait-File {
|
||||||
|
param([string]$Path, [int]$TimeoutMs = 20000)
|
||||||
|
$t = 0
|
||||||
|
while (-not (Test-Path $Path) -and $t -lt ($TimeoutMs / 150)) { Start-Sleep -Milliseconds 150; $t++ }
|
||||||
|
if (-not (Test-Path $Path)) { throw "timeout waiting for $Path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Foreground([IntPtr]$hwnd, [string]$where) {
|
||||||
|
$deadline = (Get-Date).AddMilliseconds(2000)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
if ([FgApi2]::GetForegroundWindow() -eq $hwnd) { return }
|
||||||
|
Start-Sleep -Milliseconds 30
|
||||||
|
}
|
||||||
|
throw "$where : foreground=$([FgApi2]::GetForegroundWindow()) want=$($hwnd.ToInt64()) - refusing to inject"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PhoneUiText {
|
||||||
|
[void](Adb-Shell @("uiautomator", "dump", "/sdcard/ui.xml"))
|
||||||
|
$r = Adb -AdbArgs @("pull", "/sdcard/ui.xml", "$tmp\ui.xml")
|
||||||
|
if ($r.code -ne 0 -or -not (Test-Path "$tmp\ui.xml")) { return "" }
|
||||||
|
return [System.IO.File]::ReadAllText("$tmp\ui.xml", [System.Text.Encoding]::UTF8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-Received {
|
||||||
|
if (-not (Test-Path $resultFile)) { return "" }
|
||||||
|
$b = [System.IO.File]::ReadAllBytes($resultFile)
|
||||||
|
$s = [System.Text.Encoding]::UTF8.GetString($b)
|
||||||
|
if ($s.Length -gt 0 -and $s[0] -eq [char]0xFEFF) { $s = $s.Substring(1) }
|
||||||
|
return $s
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 0. retire earlier targets (touch only existing stop flags) ==="
|
||||||
|
$flags = @(Get-ChildItem -Path $env:TEMP -Recurse -Filter "stop.flag" -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.FullName -like "*remote*proof*" })
|
||||||
|
if ($flags.Count -gt 0) {
|
||||||
|
Write-Host " retiring $($flags.Count)"
|
||||||
|
foreach ($f in $flags) { (Get-Item $f.FullName).LastWriteTime = Get-Date }
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 1. fresh target window ==="
|
||||||
|
Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass",`
|
||||||
|
"-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,`
|
||||||
|
"-ResultFile",$resultFile,"-StopFile",$stopFile,"-GrabForeground"
|
||||||
|
Wait-File $pidFile
|
||||||
|
$parts = @((Get-Content $pidFile -Raw) -split "`n")
|
||||||
|
$tid = [int]$parts[0].Trim()
|
||||||
|
$hwnd = [IntPtr][long]$parts[1].Trim()
|
||||||
|
Assert-Foreground $hwnd "after launch"
|
||||||
|
Write-Host " target pid=$tid hwnd=$($hwnd.ToInt64())"
|
||||||
|
|
||||||
|
Write-Host "=== 2. agent freshness + phone session ==="
|
||||||
|
$agentExe = "$repo\target\release\agent.exe"
|
||||||
|
$ag = Get-Process agent -ErrorAction SilentlyContinue
|
||||||
|
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
|
||||||
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 800; $ag = $null
|
||||||
|
}
|
||||||
|
if (-not $ag) {
|
||||||
|
Start-Process $agentExe -ArgumentList "--port",$Port,"--code",$Code -WindowStyle Hidden
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
[void](Adb-Shell @("am", "force-stop", "com.verify.remote"))
|
||||||
|
[void](Adb-Shell @("am", "start", "-n", "com.verify.remote/.MainActivity",
|
||||||
|
"--es", "host", $PhoneIp, "--ei", "port", "$Port", "--es", "code", $Code))
|
||||||
|
$deadline = (Get-Date).AddSeconds(25)
|
||||||
|
$xml = ""
|
||||||
|
while ((Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 900; $xml = Get-PhoneUiText; if ($xml -match "RTT=") { break } }
|
||||||
|
if ($xml -notmatch "RTT=") { throw "phone did not connect" }
|
||||||
|
Write-Host " $([regex]::Match($xml,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
|
||||||
|
|
||||||
|
Write-Host "=== 3. show the soft keyboard (tap the app's keyboard button) ==="
|
||||||
|
# Button coordinates come from the live hierarchy. Our remote-state bar adds
|
||||||
|
# "keyboard" before "disconnect", so index 0 is the keyboard button - asserted
|
||||||
|
# rather than assumed by checking the count and printing what we tapped.
|
||||||
|
$btns = [regex]::Matches($xml, '<node[^>]*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if ($btns.Count -lt 1) { throw "no buttons found in remote state" }
|
||||||
|
$b0 = $btns[0]
|
||||||
|
$kx = [int](([int]$b0.Groups[1].Value + [int]$b0.Groups[3].Value) / 2)
|
||||||
|
$ky = [int](([int]$b0.Groups[2].Value + [int]$b0.Groups[4].Value) / 2)
|
||||||
|
Write-Host " tapping first button ($kx,$ky)"
|
||||||
|
[void](Adb-Shell @("input", "tap", "$kx", "$ky"))
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
$ime = (Adb-Shell @("dumpsys", "input_method")).out
|
||||||
|
$shown = ($ime -match "mInputShown=true")
|
||||||
|
$bound = ($ime -match "mHaveConnection=true")
|
||||||
|
$curId = [regex]::Match($ime, "mCurId=(\S+)").Groups[1].Value
|
||||||
|
Write-Host " IME: shown=$shown boundToUs=$bound cur=$curId"
|
||||||
|
if (-not $shown) { throw "soft keyboard did not appear" }
|
||||||
|
if (-not $bound) { throw "no InputConnection bound - our onCheckIsTextEditor/onCreateInputConnection path failed" }
|
||||||
|
|
||||||
|
Write-Host "=== 4. type through the IME, expect commitText -> v3 Text -> Windows ==="
|
||||||
|
$before = Read-Received
|
||||||
|
Assert-Foreground $hwnd "before IME text"
|
||||||
|
[void](Adb-Shell @("input", "text", $Text))
|
||||||
|
Start-Sleep -Milliseconds 1500
|
||||||
|
# Latin input in most Chinese IMEs commits without a candidate pick; if the IME
|
||||||
|
# instead left it composing, nudge with SPACE to force a commit.
|
||||||
|
$got = Read-Received
|
||||||
|
if ($got -notmatch [regex]::Escape($Text)) {
|
||||||
|
Write-Host " not committed yet (got [$got]); sending SPACE to accept candidate"
|
||||||
|
[void](Adb-Shell @("input", "keyevent", "62")) # KEYCODE_SPACE
|
||||||
|
Start-Sleep -Milliseconds 1500
|
||||||
|
$got = Read-Received
|
||||||
|
}
|
||||||
|
Write-Host " target received: [$got] (expected to contain [$Text])"
|
||||||
|
$diag = [regex]::Match((Get-PhoneUiText), 'text="([^"]*RTT=[^"]*)"').Groups[1].Value
|
||||||
|
Write-Host " phone status: $diag"
|
||||||
|
if ($got -notmatch [regex]::Escape($Text)) {
|
||||||
|
throw "IME COMMIT FAIL: nothing from the IME reached the Windows target"
|
||||||
|
}
|
||||||
|
Write-Host " PASS: real IME -> RemoteInputConnection -> v3 Text -> Windows window"
|
||||||
|
|
||||||
|
Write-Host "=== 5. back key must close keyboard first, not the session ==="
|
||||||
|
[void](Adb-Shell @("input", "keyevent", "4"))
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$ime2 = (Adb-Shell @("dumpsys", "input_method")).out
|
||||||
|
$xml2 = Get-PhoneUiText
|
||||||
|
$stillLive = ($xml2 -match "RTT=")
|
||||||
|
$kbHidden = ($ime2 -match "mInputShown=false")
|
||||||
|
Write-Host " after BACK: session live=$stillLive keyboard hidden=$kbHidden"
|
||||||
|
if (-not ($kbHidden -and $stillLive)) { throw "BACK did not behave as keyboard-first-then-session" }
|
||||||
|
Write-Host " PASS: first BACK dismissed the keyboard, session kept"
|
||||||
|
|
||||||
|
Write-Host "=== 6. cleanup ==="
|
||||||
|
New-Item -ItemType File -Force -Path $stopFile | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
Write-Host "=== IME COMMIT PROOF COMPLETE ==="
|
||||||
486
tools/landscape_proof.ps1
Normal file
486
tools/landscape_proof.ps1
Normal file
@ -0,0 +1,486 @@
|
|||||||
|
# 4a landscape proof: rotation, full-height video, auto-hide controls, regressions.
|
||||||
|
#
|
||||||
|
# ASCII-only (PS 5.1 reads BOM-less .ps1 as GBK; personal lessons).
|
||||||
|
#
|
||||||
|
# Judgements (loop-4a-landscape-plan.md section 6):
|
||||||
|
# V-C1 connect to 1920x1080 -> app rotates landscape <=2s after live
|
||||||
|
# (LOCAL display cur= only: this vivo ROM has a permanent 1080x2400
|
||||||
|
# virtual display and prints no mRotation= line - probe lesson 2026-11-01)
|
||||||
|
# V-C2 with controls hidden, video view is 16:9 (err <2%) and >=90% screen height
|
||||||
|
# V-C3 frame counter strictly increases across rotation (session survives)
|
||||||
|
# V-C4 >=45 fps over a 2.5s window with an ACTIVE desktop animator
|
||||||
|
# (static desktop is source-limited ~25fps - measuring that would be a
|
||||||
|
# category error, probe lesson)
|
||||||
|
# V-C5 controls auto-hide ~3s idle; bottom-edge tap wakes them back
|
||||||
|
# V-C5b edge-band tap does NOT reach the remote (PC cursor delta <=1px)
|
||||||
|
# V-C5c all four buttons present after wake; mode tap changes and restores
|
||||||
|
# V-C6 form page is portrait
|
||||||
|
# V-C7 orientation button cycles AUTO->L->P->SYS->AUTO, display follows
|
||||||
|
#
|
||||||
|
# powershell -ExecutionPolicy Bypass -File tools\landscape_proof.ps1 -Host_ 192.168.1.112
|
||||||
|
param(
|
||||||
|
[string]$Host_ = "192.168.1.112",
|
||||||
|
[int]$Port = 47800,
|
||||||
|
[string]$Code = "t",
|
||||||
|
[string]$AgentExe = ""
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = (Resolve-Path "$PSScriptRoot\..").Path
|
||||||
|
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||||
|
$ev = Join-Path (Split-Path $repo -Parent) "evidence\4a"
|
||||||
|
New-Item -ItemType Directory -Force -Path $ev | Out-Null
|
||||||
|
|
||||||
|
# ---- Chinese fragments as code points (R-8) ----
|
||||||
|
$F_ORIENT = -join ([char]0x65B9,[char]0x5411,[char]0x3A) # "fangxiang:" (orientation:)
|
||||||
|
$F_AUTO = $F_ORIENT + -join ([char]0x81EA,[char]0x52A8) # label: orientation AUTO
|
||||||
|
$F_LAND = $F_ORIENT + -join ([char]0x6A2A,[char]0x5C4F) # label: orientation LANDSCAPE
|
||||||
|
$F_PORT = $F_ORIENT + -join ([char]0x7AD6,[char]0x5C4F) # label: orientation PORTRAIT
|
||||||
|
$F_SYS = $F_ORIENT + -join ([char]0x7CFB,[char]0x7EDF) # label: orientation SYSTEM
|
||||||
|
$F_CONNECT = -join ([char]0x8FDE,[char]0x63A5) # label: connect
|
||||||
|
$F_RECONNECT = -join ([char]0x91CD,[char]0x65B0,[char]0x8FDE,[char]0x63A5) # label: reconnect
|
||||||
|
$F_KB = -join ([char]0x952E,[char]0x76D8) # label: keyboard
|
||||||
|
$F_MODE = -join ([char]0x6A21,[char]0x5F0F,[char]0x3A) # "moshi:" (mode:)
|
||||||
|
$F_DISC = -join ([char]0x65AD,[char]0x5F00) # label: disconnect
|
||||||
|
|
||||||
|
function Adb {
|
||||||
|
param([Parameter(Mandatory)][string[]]$AdbArgs)
|
||||||
|
$old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
||||||
|
try { $out = & $adb @AdbArgs 2>$null; @{ out = ($out -join "`n"); code = $LASTEXITCODE } }
|
||||||
|
finally { $ErrorActionPreference = $old }
|
||||||
|
}
|
||||||
|
function AdbShell { param([string[]]$Cmd) (Adb -AdbArgs (@("shell") + $Cmd)).out }
|
||||||
|
|
||||||
|
function Get-Ui {
|
||||||
|
[void](AdbShell @("uiautomator","dump","/sdcard/ls.xml"))
|
||||||
|
$r = Adb -AdbArgs @("pull","/sdcard/ls.xml","$env:TEMP\ls.xml")
|
||||||
|
if ($r.code -ne 0 -or -not (Test-Path "$env:TEMP\ls.xml")) { return "" }
|
||||||
|
return [System.IO.File]::ReadAllText("$env:TEMP\ls.xml", [System.Text.Encoding]::UTF8)
|
||||||
|
}
|
||||||
|
# uiautomator intermittently returns an empty/failed dump (measured 2026-11-01:
|
||||||
|
# a tap lookup got "" and the null-button tap then crashed with an empty-string
|
||||||
|
# argument). Retry until the tree is plausibly real; callers still assert on
|
||||||
|
# their own node - this only removes the transient, never masks a real absence.
|
||||||
|
function Get-UiRetry([int]$tries=4) {
|
||||||
|
$x = ""
|
||||||
|
for ($i=0; $i -lt $tries; $i++) {
|
||||||
|
$x = Get-Ui
|
||||||
|
if ($x.Length -gt 500) { return $x }
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
}
|
||||||
|
Write-Host " [ui] retry exhausted, last dump len=$($x.Length)"
|
||||||
|
return $x
|
||||||
|
}
|
||||||
|
function VisibleTexts([string]$xml) {
|
||||||
|
[regex]::Matches($xml,'text="([^"]+)"') | ForEach-Object { [System.Net.WebUtility]::HtmlDecode($_.Groups[1].Value) }
|
||||||
|
}
|
||||||
|
function ButtonByName([string]$xml, [string]$want, [bool]$prefix=$false) {
|
||||||
|
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||||
|
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||||
|
$hit = if ($prefix) { $t.StartsWith($want) } else { $t -eq $want }
|
||||||
|
if ($hit) {
|
||||||
|
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if ($b.Success) {
|
||||||
|
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
|
||||||
|
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
|
||||||
|
return @{ cx=[int](($x1+$x2)/2); cy=[int](($y1+$y2)/2) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
function ButtonLabelByPrefix([string]$xml, [string]$prefix) {
|
||||||
|
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||||
|
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||||
|
if ($t.StartsWith($prefix)) { return $t }
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
# 4a race (measured 2026-11-01): each uiautomator dump costs ~2s, comparable to
|
||||||
|
# the 3s auto-hide timer - find-then-read sequences straddle expiry and labels
|
||||||
|
# come back empty. Every observation goes through the helpers below.
|
||||||
|
#
|
||||||
|
# CRITICAL SAFETY: the wake tap (screen bottom center) is only consumed by the
|
||||||
|
# app's edge band WHEN CONTROLS ARE HIDDEN. If controls are visible, that point
|
||||||
|
# lands on the control bar (mode/orientation buttons!) and would fire a real
|
||||||
|
# action. So a wake is allowed only after positively proving the hidden state:
|
||||||
|
# video View node present AND zero Button nodes.
|
||||||
|
function ControlsHidden([string]$ui) {
|
||||||
|
$anyBtn = ($ui -match 'class="android\.widget\.Button"')
|
||||||
|
$video = ($ui -match 'class="android\.view\.View"')
|
||||||
|
return ($video -and -not $anyBtn)
|
||||||
|
}
|
||||||
|
function Wake-Band {
|
||||||
|
# 4a design change (measured 2026-11-01): the wake zone is the LETTERBOX
|
||||||
|
# BLACK BAR, not a bottom edge band. With controls hidden the video is
|
||||||
|
# full-height, so bottom-center lies INSIDE the picture and would fire a
|
||||||
|
# real remote click. x=100 is outside the video in both states.
|
||||||
|
$d = LocalDisplay
|
||||||
|
[void](AdbShell @("input","tap","100","$([int]($d.h/2))"))
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
}
|
||||||
|
function Find-VisibleButton([string]$want, [bool]$prefix=$false) {
|
||||||
|
for ($try=0; $try -lt 5; $try++) {
|
||||||
|
$ui = Get-UiRetry
|
||||||
|
$b = ButtonByName $ui $want $prefix
|
||||||
|
if ($b) { return $b }
|
||||||
|
if (ControlsHidden $ui) { Wake-Band; continue }
|
||||||
|
if ($ui.Length -gt 500) { return $null } # real tree, genuine absence
|
||||||
|
Start-Sleep -Milliseconds 400 # empty-dump transient
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
function Tap-VisibleButton([string]$want, [bool]$prefix=$false) {
|
||||||
|
$b = Find-VisibleButton $want $prefix
|
||||||
|
if (-not $b) { return $null }
|
||||||
|
[void](AdbShell @("input","tap","$($b.cx)","$($b.cy)"))
|
||||||
|
return $b
|
||||||
|
}
|
||||||
|
# Label read that survives the hide race: wake only from a proven-hidden state.
|
||||||
|
function Read-ButtonLabel([string]$want, [bool]$prefix=$false) {
|
||||||
|
for ($try=0; $try -lt 6; $try++) {
|
||||||
|
$ui = Get-UiRetry
|
||||||
|
$l = ButtonLabelByPrefix $ui $want
|
||||||
|
if ($l) { return $l }
|
||||||
|
if (ControlsHidden $ui) { Wake-Band; continue }
|
||||||
|
if ($ui.Length -gt 500) { return $null }
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
function FrameCount {
|
||||||
|
$m = [regex]::Match((Get-Ui), ([char]0x5E27 + '=(\d+)'))
|
||||||
|
if ($m.Success) { return [long]$m.Groups[1].Value }
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
# 4a self-conflict (measured 2026-11-01): once the controls auto-hide, the HUD
|
||||||
|
# TextView is GONE too - uiautomator's tree has NO text nodes and FrameCount
|
||||||
|
# returns -1 even though the session is alive (the video View is right there).
|
||||||
|
# Wake through the bottom edge band first: that tap is consumed by the app
|
||||||
|
# (zero remote events), so it cannot perturb the fps window.
|
||||||
|
function WakeControls {
|
||||||
|
$d = LocalDisplay
|
||||||
|
[void](AdbShell @("input","tap","100","$([int]($d.h/2))"))
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
function FrameCountOrWake {
|
||||||
|
$f = FrameCount
|
||||||
|
if ($f -ge 0) { return $f }
|
||||||
|
WakeControls
|
||||||
|
return FrameCount
|
||||||
|
}
|
||||||
|
function LocalDisplay {
|
||||||
|
$d = (AdbShell @("dumpsys","window","displays"))
|
||||||
|
$idx = $d.IndexOf("Display: mDisplayId=0")
|
||||||
|
if ($idx -lt 0) { return @{ w=0; h=0 } }
|
||||||
|
$m = [regex]::Match($d.Substring($idx), 'cur=(\d+)x(\d+)')
|
||||||
|
if (-not $m.Success) { return @{ w=0; h=0 } }
|
||||||
|
return @{ w=[int]$m.Groups[1].Value; h=[int]$m.Groups[2].Value }
|
||||||
|
}
|
||||||
|
function Wake {
|
||||||
|
[void](AdbShell @("input","keyevent","224"))
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
[void](AdbShell @("wm","dismiss-keyguard"))
|
||||||
|
Start-Sleep -Milliseconds 350
|
||||||
|
}
|
||||||
|
function Restart-AppForm {
|
||||||
|
Wake
|
||||||
|
[void](AdbShell @("am","force-stop","com.verify.remote"))
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
[void](AdbShell @("am","start","-n","com.verify.remote/.MainActivity"))
|
||||||
|
$deadline = (Get-Date).AddSeconds(8)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$x = Get-Ui
|
||||||
|
if (([regex]::Matches($x,'class="android\.widget\.EditText"')).Count -ge 3) { return $x }
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
}
|
||||||
|
throw "form did not appear: [$((VisibleTexts (Get-Ui)) -join ' || ')]"
|
||||||
|
}
|
||||||
|
function CenterOf($m) {
|
||||||
|
$a=[int]$m.Groups[1].Value; $b=[int]$m.Groups[2].Value
|
||||||
|
$c=[int]$m.Groups[3].Value; $e=[int]$m.Groups[4].Value
|
||||||
|
@([int](($a+$c)/2), [int](($b+$e)/2))
|
||||||
|
}
|
||||||
|
function TypeAscii([string]$s) {
|
||||||
|
foreach ($ch in $s.ToUpper().ToCharArray()) {
|
||||||
|
$k = if ($ch -ge '0' -and $ch -le '9') { 7 + [int][string]$ch }
|
||||||
|
elseif ($ch -ge 'A' -and $ch -le 'Z') { 29 + ([int]$ch - [int][char]'A') }
|
||||||
|
elseif ($ch -eq '.') { 56 } else { throw "unsupported char $ch" }
|
||||||
|
[void](AdbShell @("input","keyevent","$k"))
|
||||||
|
Start-Sleep -Milliseconds 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function ClearField($c) {
|
||||||
|
for ($try=0; $try -lt 3; $try++) {
|
||||||
|
[void](AdbShell @("input","tap","$($c[0])","$($c[1])"))
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
for ($i=0; $i -lt 40; $i++) { [void](AdbShell @("input","keyevent","67")) }
|
||||||
|
Start-Sleep -Milliseconds 120
|
||||||
|
$fx = Get-Ui
|
||||||
|
$focused = $false
|
||||||
|
$fi = -1
|
||||||
|
foreach ($n in [regex]::Matches($fx,'<node[^>]*>')) {
|
||||||
|
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
||||||
|
$fi++
|
||||||
|
if ($n.Value -match 'focused="true"') { $focused = $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($focused) { return }
|
||||||
|
}
|
||||||
|
throw "field focus failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# PC-side cursor (V-C5b) + desktop animator (V-C4)
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System; using System.Runtime.InteropServices;
|
||||||
|
public class Cur4a {
|
||||||
|
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
|
||||||
|
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr c);
|
||||||
|
public static void Dpi(){ SetProcessDpiAwarenessContext(new IntPtr(-4)); }
|
||||||
|
public static int[] Get(){ POINT p; GetCursorPos(out p); return new int[]{p.X,p.Y}; }
|
||||||
|
public static void Set(int x,int y){ SetCursorPos(x,y); }
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
[Cur4a]::Dpi()
|
||||||
|
|
||||||
|
Write-Host "=== 0. memory precheck (probe lesson: OOM fakes failures) ==="
|
||||||
|
$os = Get-CimInstance Win32_OperatingSystem
|
||||||
|
$freeGB = $os.FreeVirtualMemory/1MB
|
||||||
|
Write-Host (" commit free: {0:N1} GB" -f $freeGB)
|
||||||
|
if ($freeGB -lt 0.5) { throw "commit memory critically low - free RAM first (gradle --stop)" }
|
||||||
|
|
||||||
|
Write-Host "=== 1. stale-agent guard (R-7) ==="
|
||||||
|
if (-not $AgentExe) { $AgentExe = Join-Path $repo "target\release\agent.exe" }
|
||||||
|
$ag = @(Get-Process agent -ErrorAction SilentlyContinue)
|
||||||
|
if ($ag.Count -gt 0 -and (Test-Path $AgentExe) -and ((Get-Item $AgentExe).LastWriteTime -gt $ag[0].StartTime)) {
|
||||||
|
Write-Host " agent predates build; restarting"
|
||||||
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 800; $ag = @()
|
||||||
|
}
|
||||||
|
if ($ag.Count -eq 0) {
|
||||||
|
Start-Process $AgentExe -ArgumentList "--port","$Port","--code",$Code -WindowStyle Hidden | Out-Null
|
||||||
|
Start-Sleep -Seconds 2; Write-Host " agent started"
|
||||||
|
} else { Write-Host " agent running and current" }
|
||||||
|
|
||||||
|
$origIme = ((AdbShell @("settings","get","secure","default_input_method")) -join "").Trim()
|
||||||
|
if ($origIme -and $origIme -ne "null") { [void](AdbShell @("ime","disable",$origIme)) }
|
||||||
|
[void](AdbShell @("svc","power","stayon","true"))
|
||||||
|
|
||||||
|
try {
|
||||||
|
# =====================================================================
|
||||||
|
Write-Host "=== 2. V-C6: form is portrait ==="
|
||||||
|
$x = Restart-AppForm
|
||||||
|
$d = LocalDisplay
|
||||||
|
Write-Host (" form display: {0}x{1}" -f $d.w, $d.h)
|
||||||
|
if (-not ($d.w -lt $d.h)) { throw "V-C6 FAIL: form not portrait ($($d.w)x$($d.h))" }
|
||||||
|
Write-Host " PASS: form portrait"
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
Write-Host "=== 3. V-C1: connect -> AUTO rotates landscape <=2s after live ==="
|
||||||
|
$F_edits = [regex]::Matches($x,'<node[^>]*class="android\.widget\.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
ClearField (CenterOf $F_edits[0]); TypeAscii $Host_
|
||||||
|
ClearField (CenterOf $F_edits[1]); TypeAscii "$Port"
|
||||||
|
ClearField (CenterOf $F_edits[2]); TypeAscii $Code
|
||||||
|
# Read back until 3 EditText nodes are actually present: uiautomator
|
||||||
|
# intermittently returns a truncated/empty tree (measured 2026-11-01, worse
|
||||||
|
# under memory pressure) and a single Get-Ui then reads "form now: " empty
|
||||||
|
# and fails the fill check as a false negative.
|
||||||
|
$vals = @()
|
||||||
|
for ($rd=0; $rd -lt 5; $rd++) {
|
||||||
|
$x2 = Get-UiRetry
|
||||||
|
$vals = @()
|
||||||
|
foreach ($n in [regex]::Matches($x2,'<node[^>]*>')) {
|
||||||
|
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
||||||
|
$vals += [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($vals.Count -ge 3) { break }
|
||||||
|
Write-Host " form readback got $($vals.Count) fields, retry $rd"
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
Write-Host " form now: $($vals -join ' | ')"
|
||||||
|
if ($vals.Count -lt 3) { throw "form fill FAIL: only $($vals.Count) fields after retries" }
|
||||||
|
if ($vals[0] -ne $Host_ -or $vals[1] -ne "$Port" -or $vals[2] -ne $Code) { throw "form fill FAIL" }
|
||||||
|
$btn = ButtonByName $x2 $F_CONNECT
|
||||||
|
if (-not $btn) { $btn = ButtonByName $x2 $F_RECONNECT }
|
||||||
|
if (-not $btn) { $btn = Find-VisibleButton $F_CONNECT; if (-not $btn) { $btn = Find-VisibleButton $F_RECONNECT } }
|
||||||
|
if (-not $btn) { throw "connect button not found" }
|
||||||
|
[void](AdbShell @("input","tap","$($btn.cx)","$($btn.cy)"))
|
||||||
|
$deadline = (Get-Date).AddSeconds(20); $live = $false
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
if ((Get-Ui) -match "RTT=") { $live = $true; break }
|
||||||
|
}
|
||||||
|
if (-not $live) { throw "never reached live: [$((VisibleTexts (Get-Ui)) -join ' || ')]" }
|
||||||
|
$tRot = Get-Date
|
||||||
|
$deadline = $tRot.AddSeconds(2); $rotated = $false
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$d = LocalDisplay
|
||||||
|
if ($d.w -gt $d.h) { $rotated = $true; break }
|
||||||
|
Start-Sleep -Milliseconds 150
|
||||||
|
}
|
||||||
|
$ms = ((Get-Date) - $tRot).TotalMilliseconds
|
||||||
|
Write-Host (" display {0}x{1}, landscape within {2:N0}ms of live" -f $d.w,$d.h,$ms)
|
||||||
|
if (-not $rotated) { throw "V-C1 FAIL: still portrait 2s after live" }
|
||||||
|
Write-Host " PASS: AUTO rotated to landscape"
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
Write-Host "=== 4. V-C3 + V-C4: counter monotonic; >=45fps with animated desktop ==="
|
||||||
|
# Static desktop is source-limited (~25fps): the honest window needs motion.
|
||||||
|
$anim = Start-Job -ScriptBlock {
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System; using System.Runtime.InteropServices;
|
||||||
|
public class An { [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); }
|
||||||
|
"@
|
||||||
|
$end=(Get-Date).AddSeconds(14); $i=0
|
||||||
|
while ((Get-Date) -lt $end) {
|
||||||
|
$a=$i*0.18
|
||||||
|
[An]::SetCursorPos([int](960+320*[Math]::Cos($a)), [int](540+320*[Math]::Sin($a))) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 12; $i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 2 # let the encoder see motion
|
||||||
|
$fA = FrameCountOrWake; $ta = Get-Date
|
||||||
|
Start-Sleep -Milliseconds 2500
|
||||||
|
$fB = FrameCountOrWake; $tb = Get-Date
|
||||||
|
$fps = if ($fA -ge 0 -and $fB -gt $fA) { [Math]::Round(($fB-$fA)/(($tb-$ta).TotalSeconds),1) } else { -1 }
|
||||||
|
Write-Host " frames $fA -> $fB fps=$fps (animated)"
|
||||||
|
if ($fB -le $fA) { throw "V-C3 FAIL: counter stalled across rotation" }
|
||||||
|
Write-Host " PASS: V-C3 session survived rotation"
|
||||||
|
if ($fps -lt 45) { throw "V-C4 FAIL: $fps fps < 45 with animated desktop" }
|
||||||
|
Write-Host " PASS: V-C4 fps >= 45"
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
Write-Host "=== 5. V-C5: wake -> visible -> idle -> hidden ==="
|
||||||
|
# R-8 discipline: "button absent" is NOT a valid judgement on its own - an
|
||||||
|
# empty/broken dump would pass it trivially (measured 2026-11-01: after the
|
||||||
|
# hide, the HUD TextView is GONE too, so the tree has zero text nodes).
|
||||||
|
# Positive pair: keyboard button absent WHILE the video View node is present.
|
||||||
|
#
|
||||||
|
# Sequence is explicit (first run's implicit precondition raced: the fps
|
||||||
|
# step's wake expired its 3s timer before we checked, so controls were
|
||||||
|
# already hidden - correct product behavior, wrong harness assumption):
|
||||||
|
# wake -> assert visible -> idle -> assert hidden.
|
||||||
|
WakeControls
|
||||||
|
$x5 = Get-Ui
|
||||||
|
if (-not (ButtonByName $x5 $F_KB)) { throw "V-C5 FAIL: edge wake did not bring controls back" }
|
||||||
|
Write-Host " visible after wake: OK"
|
||||||
|
$deadline = (Get-Date).AddSeconds(6); $hidden = $false
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
$x5 = Get-Ui
|
||||||
|
$videoSeen = ($x5 -match 'class="android\.view\.View"')
|
||||||
|
$kbGone = -not (ButtonByName $x5 $F_KB)
|
||||||
|
if ($videoSeen -and $kbGone) { $hidden = $true; break }
|
||||||
|
}
|
||||||
|
if (-not $hidden) {
|
||||||
|
throw "V-C5 FAIL: no (kb-gone AND video-present) state within 6s; last dump len=$($x5.Length)" }
|
||||||
|
Write-Host " PASS: controls auto-hidden (video View still in tree - real hide, not empty dump)"
|
||||||
|
|
||||||
|
Write-Host "=== 5b. V-C2: video 16:9 and >=90% screen height (controls hidden) ==="
|
||||||
|
$ui = Get-UiRetry
|
||||||
|
$best = $null; $bestErr = 1e9
|
||||||
|
foreach ($n in [regex]::Matches($ui,'<node[^>]*class="android\.view\.View"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')) {
|
||||||
|
$x1=[int]$n.Groups[1].Value; $y1=[int]$n.Groups[2].Value
|
||||||
|
$x2i=[int]$n.Groups[3].Value; $y2=[int]$n.Groups[4].Value
|
||||||
|
$w=$x2i-$x1; $h=$y2-$y1
|
||||||
|
if ($w -lt 300 -or $h -lt 100) { continue }
|
||||||
|
$err = [Math]::Abs($w/[double]$h - 16.0/9.0)
|
||||||
|
if ($err -lt $bestErr) { $bestErr=$err; $best=@{w=$w;h=$h} }
|
||||||
|
}
|
||||||
|
$d = LocalDisplay
|
||||||
|
$hFrac = $best.h / [double]$d.h
|
||||||
|
Write-Host (" video {0}x{1} ratio err {2:N4} height {3:N1}% of {4}x{5}" -f `
|
||||||
|
$best.w,$best.h,$bestErr,($hFrac*100),$d.w,$d.h)
|
||||||
|
if ($bestErr -gt 0.02) { throw "V-C2 FAIL: video not 16:9 (err $bestErr)" }
|
||||||
|
if ($hFrac -lt 0.90) { throw ("V-C2 FAIL: video height {0:N1}% < 90%" -f ($hFrac*100)) }
|
||||||
|
Write-Host " PASS: full-height 16:9 video (the tiny-video fix)"
|
||||||
|
Stop-Job $anim -ErrorAction SilentlyContinue; Remove-Job $anim -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Write-Host "=== 6. V-C5b: black-bar tap wakes controls WITHOUT touching the remote ==="
|
||||||
|
Start-Sleep -Seconds 2 # animator fully stopped
|
||||||
|
# The wake zone IS the letterbox black bar now (4a design change), so this
|
||||||
|
# is the honest target. Also assert a video-interior tap is NOT consumed as
|
||||||
|
# a wake - otherwise the black bar would have eaten real clicks too.
|
||||||
|
$pc0 = [Cur4a]::Get()
|
||||||
|
[void](AdbShell @("input","tap","100","$([int]($d.h/2))"))
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
$pc1 = [Cur4a]::Get()
|
||||||
|
$ui = Get-UiRetry
|
||||||
|
$kb = ButtonByName $ui $F_KB
|
||||||
|
$jump = [Math]::Max([Math]::Abs($pc1[0]-$pc0[0]), [Math]::Abs($pc1[1]-$pc0[1]))
|
||||||
|
Write-Host (" black-bar tap: cursor jump=$jump px ; keyboard back: {0}" -f ($null -ne $kb))
|
||||||
|
if (-not $kb) { throw "V-C5 FAIL: black-bar tap did not wake controls" }
|
||||||
|
if ($jump -gt 1) { throw "V-C5b FAIL: black-bar tap leaked $jump px to remote" }
|
||||||
|
Write-Host " PASS: wake works, zero remote leakage"
|
||||||
|
|
||||||
|
Write-Host "=== 6c. four buttons present; mode tap changes and restores ==="
|
||||||
|
$kb2 = Find-VisibleButton $F_KB
|
||||||
|
$mode = Find-VisibleButton $F_MODE $true
|
||||||
|
$disc = Find-VisibleButton $F_DISC
|
||||||
|
$orient = Find-VisibleButton $F_ORIENT $true
|
||||||
|
if (-not ($kb2 -and $mode -and $disc -and $orient)) {
|
||||||
|
throw ("V-C5 FAIL: missing buttons kb={0} mode={1} disc={2} orient={3}" -f `
|
||||||
|
($null -ne $kb2),($null -ne $mode),($null -ne $disc),($null -ne $orient)) }
|
||||||
|
$beforeLabel = Read-ButtonLabel $F_MODE $true
|
||||||
|
if (-not $beforeLabel) { throw "V-C5 precondition FAIL: mode label unreadable" }
|
||||||
|
[void](Tap-VisibleButton $F_MODE $true)
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
$afterLabel = Read-ButtonLabel $F_MODE $true
|
||||||
|
Write-Host " mode label: [$beforeLabel] -> [$afterLabel]"
|
||||||
|
# null would trivially "change" from a real label - assert non-empty too (R-8)
|
||||||
|
if (-not $afterLabel -or $beforeLabel -eq $afterLabel) { throw "V-C5 FAIL: mode tap changed nothing" }
|
||||||
|
# converge back to relative (the product default the later proofs assume),
|
||||||
|
# regardless of which way the flip above landed - tap until it reads relative.
|
||||||
|
$F_MODE_REL = $F_MODE + -join ([char]0x76F8,[char]0x5BF9) # mode: relative
|
||||||
|
$converged = $false
|
||||||
|
for ($i=0; $i -lt 3 -and -not $converged; $i++) {
|
||||||
|
$cur = Read-ButtonLabel $F_MODE $true
|
||||||
|
if ($cur -eq $F_MODE_REL) { $converged = $true; break }
|
||||||
|
[void](Tap-VisibleButton $F_MODE $true)
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
}
|
||||||
|
$restored = Read-ButtonLabel $F_MODE $true
|
||||||
|
Write-Host " mode converged to relative: [$restored]"
|
||||||
|
if (-not $converged -or $restored -ne $F_MODE_REL) {
|
||||||
|
throw "V-C5 FAIL: mode not converged to relative (pollutes later proofs)" }
|
||||||
|
Write-Host " PASS: buttons work, state restored"
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
Write-Host "=== 7. V-C7: orientation cycle AUTO->L->P->SYS->AUTO ==="
|
||||||
|
$seq = @($F_LAND, $F_PORT, $F_SYS, $F_AUTO)
|
||||||
|
$wantLand = @($true, $false, $null, $true) # null = don't assert SYSTEM policy
|
||||||
|
for ($i=0; $i -lt 4; $i++) {
|
||||||
|
$orient = Find-VisibleButton $F_ORIENT $true
|
||||||
|
if (-not $orient) { throw "V-C7 FAIL: orientation button unreachable after 5 wakes" }
|
||||||
|
[void](Tap-VisibleButton $F_ORIENT $true)
|
||||||
|
Start-Sleep -Milliseconds 1400
|
||||||
|
$label = Read-ButtonLabel $F_ORIENT $true
|
||||||
|
$d = LocalDisplay
|
||||||
|
$land = $d.w -gt $d.h
|
||||||
|
Write-Host (" step {0}: label=[{1}] want=[{2}] display={3}x{4} landscape={5}" -f ($i+1),$label,$seq[$i],$d.w,$d.h,$land)
|
||||||
|
if ($label -ne $seq[$i]) { throw ("V-C7 FAIL: step {0} label [{1}] != [{2}]" -f ($i+1),$label,$seq[$i]) }
|
||||||
|
if ($null -ne $wantLand[$i] -and $land -ne $wantLand[$i]) {
|
||||||
|
throw ("V-C7 FAIL: step {0} landscape={1} want={2}" -f ($i+1),$land,$wantLand[$i]) }
|
||||||
|
}
|
||||||
|
Write-Host " PASS: full cycle with display following each policy"
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-c7-final.png" | Out-Null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# 4a pollution discipline: leave no live session behind - the next proof's
|
||||||
|
# Restart-AppForm force-stops the app mid-session, which strands the agent's
|
||||||
|
# serial accept in a zombie (N11) and the run after that fails to connect
|
||||||
|
# (measured 2026-11-01: agent alive but no longer serving handshakes).
|
||||||
|
[void](AdbShell @("am","force-stop","com.verify.remote"))
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
Get-Job | Stop-Job -ErrorAction SilentlyContinue
|
||||||
|
Get-Job | Remove-Job -Force -ErrorAction SilentlyContinue
|
||||||
|
[void](AdbShell @("svc","power","stayon","false"))
|
||||||
|
if ($origIme -and $origIme -ne "null") {
|
||||||
|
[void](AdbShell @("ime","enable",$origIme))
|
||||||
|
[void](AdbShell @("ime","set",$origIme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== LANDSCAPE PROOF COMPLETE ==="
|
||||||
315
tools/phone_chain_proof.ps1
Normal file
315
tools/phone_chain_proof.ps1
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
# 2b full-chain proof (phone side): Android key -> v3 Text channel -> agent
|
||||||
|
# SendInput -> dedicated Windows target window, read back from that window.
|
||||||
|
# ASCII-only source (PS 5.1 + BOM-less = GBK corruption; see personal lessons).
|
||||||
|
# State checks use ASCII markers ("RTT=" / EditText presence) rather than
|
||||||
|
# matching the app's Chinese labels, for the same reason.
|
||||||
|
#
|
||||||
|
# Why ONE atomic script: SendInput lands on the FOREGROUND window, and Windows'
|
||||||
|
# foreground lock means a background process cannot simply reclaim it. This
|
||||||
|
# box's interactive GUI steals foreground between separate tool calls, so
|
||||||
|
# launch -> assert foreground -> drive phone -> read back must run without
|
||||||
|
# yielding. Every injection is preceded by a foreground assertion: if the target
|
||||||
|
# lost foreground we ABORT rather than inject into some other application.
|
||||||
|
#
|
||||||
|
# Prereqs: vivo device online via adb, app installed, v3 agent build present.
|
||||||
|
# Usage:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File tools\phone_chain_proof.ps1
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$PhoneIp = "192.168.1.180",
|
||||||
|
[int]$Port = 47800,
|
||||||
|
[string]$Code = "t"
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = (Resolve-Path "$PSScriptRoot\..").Path
|
||||||
|
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||||
|
# UNIQUE per-run dir (measured 2026-11-01): with a fixed path, this script's
|
||||||
|
# own startup Remove-Item deleted the stop.flag that LEFTOVER targets from a
|
||||||
|
# previous failed run were watching - they could never retire, kept grabbing
|
||||||
|
# foreground every 60ms, and wrote their textbox content into the shared
|
||||||
|
# received.txt ("abc\nabc" = old run's abc+ENTER + this run's abc).
|
||||||
|
$tmp = "$env:TEMP\remote-phone-proof-$PID"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||||
|
$pidFile = "$tmp\target.pid"
|
||||||
|
$stopFile = "$tmp\stop.flag"
|
||||||
|
$resultFile = "$tmp\received.txt"
|
||||||
|
Remove-Item $pidFile,$stopFile,$resultFile -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class FgApi {
|
||||||
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
|
||||||
|
function Adb {
|
||||||
|
param([Parameter(Mandatory)][string[]]$AdbArgs)
|
||||||
|
# adb writes progress ("1 file pulled...") to stderr; PS 5.1 with
|
||||||
|
# $ErrorActionPreference=Stop turns every stderr line into a terminating
|
||||||
|
# error. Same workaround build-android.ps1 documents: relax for native
|
||||||
|
# calls, judge by exit code and captured stdout.
|
||||||
|
$old = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
$out = & $adb @AdbArgs 2>$null
|
||||||
|
return @{ out = ($out -join "`n"); code = $LASTEXITCODE }
|
||||||
|
}
|
||||||
|
finally { $ErrorActionPreference = $old }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Adb-Shell {
|
||||||
|
param([Parameter(Mandatory)][string[]]$Cmd)
|
||||||
|
return Adb -AdbArgs (@("shell") + $Cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-File {
|
||||||
|
param([string]$Path, [int]$TimeoutMs = 20000)
|
||||||
|
$t = 0
|
||||||
|
while (-not (Test-Path $Path) -and $t -lt ($TimeoutMs / 150)) { Start-Sleep -Milliseconds 150; $t++ }
|
||||||
|
if (-not (Test-Path $Path)) { throw "timeout waiting for $Path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Foreground([IntPtr]$hwnd, [string]$where) {
|
||||||
|
# Bounded wait, not a single instant check: the target re-asserts itself
|
||||||
|
# every 60ms in proof mode, so a momentary steal by the interactive desktop
|
||||||
|
# is recoverable. Still refuse to inject if it never comes back - a stray
|
||||||
|
# injection lands in whatever window DOES hold foreground.
|
||||||
|
$deadline = (Get-Date).AddMilliseconds(1500)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
if ([FgApi]::GetForegroundWindow() -eq $hwnd) { return }
|
||||||
|
Start-Sleep -Milliseconds 30
|
||||||
|
}
|
||||||
|
throw "$where : foreground=$([FgApi]::GetForegroundWindow()) want=$hwnd after 1.5s - refusing to inject"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-Received {
|
||||||
|
Wait-File $resultFile 8000
|
||||||
|
$b = [System.IO.File]::ReadAllBytes($resultFile)
|
||||||
|
$s = [System.Text.Encoding]::UTF8.GetString($b)
|
||||||
|
if ($s.Length -gt 0 -and $s[0] -eq [char]0xFEFF) { $s = $s.Substring(1) }
|
||||||
|
return $s
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PhoneUiText {
|
||||||
|
[void](Adb-Shell @("uiautomator", "dump", "/sdcard/ui.xml"))
|
||||||
|
$r = Adb -AdbArgs @("pull", "/sdcard/ui.xml", "$tmp\ui.xml")
|
||||||
|
if ($r.code -ne 0 -or -not (Test-Path "$tmp\ui.xml")) { return "" }
|
||||||
|
return [System.IO.File]::ReadAllText("$tmp\ui.xml", [System.Text.Encoding]::UTF8)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4a adaptation: in landscape the HUD TextView hides WITH the auto-hiding
|
||||||
|
# control bar, so "RTT=" disappears while the session is perfectly alive
|
||||||
|
# (measured 2026-11-01: this proof died at step 4 "session died after ENTER"
|
||||||
|
# on a healthy 22fps stream). Detect the hidden state (video View node present,
|
||||||
|
# zero Button nodes) and wake via the letterbox black bar - that tap is
|
||||||
|
# consumed by the app and never reaches the remote.
|
||||||
|
function Phone-DisplaySize {
|
||||||
|
# NB: this script's Adb-Shell returns @{out;code}, not a bare string.
|
||||||
|
$d = (Adb-Shell @("dumpsys", "window", "displays")).out
|
||||||
|
$idx = $d.IndexOf("Display: mDisplayId=0")
|
||||||
|
if ($idx -lt 0) { return @{ w = 1080; h = 2400 } }
|
||||||
|
$m = [regex]::Match($d.Substring($idx), 'cur=(\d+)x(\d+)')
|
||||||
|
if (-not $m.Success) { return @{ w = 1080; h = 2400 } }
|
||||||
|
return @{ w = [int]$m.Groups[1].Value; h = [int]$m.Groups[2].Value }
|
||||||
|
}
|
||||||
|
function Wake-Controls-If-Hidden([string]$xml) {
|
||||||
|
$hidden = ($xml -match 'class="android\.view\.View"') -and ($xml -notmatch 'class="android\.widget\.Button"')
|
||||||
|
if (-not $hidden) { return $false }
|
||||||
|
$pd = Phone-DisplaySize
|
||||||
|
[void](Adb-Shell @("input", "tap", "100", "$([int]($pd.h / 2))"))
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-For-LiveSession([int]$Seconds, [ref]$outXml) {
|
||||||
|
$deadline = (Get-Date).AddSeconds($Seconds)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 900
|
||||||
|
$x = Get-PhoneUiText
|
||||||
|
if ($x -match "RTT=") { $outXml.Value = $x; return $true }
|
||||||
|
if (Wake-Controls-If-Hidden $x) {
|
||||||
|
$x = Get-PhoneUiText
|
||||||
|
if ($x -match "RTT=") { $outXml.Value = $x; return $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$outXml.Value = (Get-PhoneUiText)
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 0. stale-agent guard (same rule as the desktop proof) ==="
|
||||||
|
$agentExe = "$repo\target\release\agent.exe"
|
||||||
|
$ag = Get-Process agent -ErrorAction SilentlyContinue
|
||||||
|
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
|
||||||
|
Write-Host " running agent predates current build; restarting it"
|
||||||
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
$ag = $null
|
||||||
|
}
|
||||||
|
if (-not $ag) {
|
||||||
|
Start-Process $agentExe -ArgumentList "--port",$Port,"--code",$Code -WindowStyle Hidden
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
Write-Host " agent started (v3)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 1. launch target window (own process, empty field) ==="
|
||||||
|
# Retire leftovers from earlier runs WITHOUT killing them. Killing a foreground
|
||||||
|
# window hands foreground to the next app (this box's interactive GUI), and the
|
||||||
|
# foreground lock then refuses a brand-new background process any claim - the
|
||||||
|
# new target could never take foreground back.
|
||||||
|
# Signal only stop files that ALREADY EXIST: creating a fresh one here would
|
||||||
|
# hit the stop path of the run we are about to start, and that target would
|
||||||
|
# close itself the moment it launched (seen 2026-10-30: pid reported then gone).
|
||||||
|
$existingFlags = @(Get-ChildItem -Path $env:TEMP -Recurse -Filter "stop.flag" -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.FullName -like "*remote*proof*" })
|
||||||
|
if ($existingFlags.Count -gt 0) {
|
||||||
|
Write-Host " retiring $($existingFlags.Count) earlier target(s) via their own stop file"
|
||||||
|
foreach ($f in $existingFlags) { (Get-Item $f.FullName).LastWriteTime = Get-Date }
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
}
|
||||||
|
Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass",`
|
||||||
|
"-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,`
|
||||||
|
"-ResultFile",$resultFile,"-StopFile",$stopFile,"-GrabForeground"
|
||||||
|
Wait-File $pidFile
|
||||||
|
# pid file carries "pid<n><hwnd>": the target writes its own handle once the
|
||||||
|
# form is realized, which removes the Get-Process MainWindowHandle race.
|
||||||
|
$parts = @((Get-Content $pidFile -Raw) -split "`n")
|
||||||
|
$tid = [int]$parts[0].Trim()
|
||||||
|
$hwnd = [IntPtr][long]$parts[1].Trim()
|
||||||
|
if ($hwnd -eq 0) { throw "target reported a null window handle" }
|
||||||
|
Assert-Foreground $hwnd "after launch"
|
||||||
|
Write-Host " target pid=$tid hwnd=$($hwnd.ToInt64()) foreground=yes"
|
||||||
|
|
||||||
|
Write-Host "=== 2. start app, connect via intent extras ==="
|
||||||
|
[void](Adb-Shell @("am", "force-stop", "com.verify.remote"))
|
||||||
|
[void](Adb-Shell @("am", "start", "-n", "com.verify.remote/.MainActivity",
|
||||||
|
"--es", "host", $PhoneIp, "--ei", "port", "$Port", "--es", "code", $Code))
|
||||||
|
$xmlRef = ""
|
||||||
|
if (-not (Wait-For-LiveSession 25 ([ref]$xmlRef))) { throw "phone never reported a live session (no RTT marker)" }
|
||||||
|
$script:xml = $xmlRef
|
||||||
|
Write-Host " phone: $([regex]::Match($script:xml,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
|
||||||
|
Assert-Foreground $hwnd "after connect"
|
||||||
|
|
||||||
|
Write-Host "=== 3. phone keys A B C -> Activity.onKeyDown -> v3 Text channel ==="
|
||||||
|
# 29/30/31 = KEYCODE_A/B/C. adb-injected keys arrive with a printable
|
||||||
|
# unicodeChar, so the app routes them to nativeSendText (IME hidden). That is
|
||||||
|
# the product path introduced in 2b, not a stand-in for it.
|
||||||
|
foreach ($k in 29,30,31) {
|
||||||
|
Assert-Foreground $hwnd "before keyevent $k"
|
||||||
|
[void](Adb-Shell @("input", "keyevent", "$k"))
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
$deadline = (Get-Date).AddSeconds(10)
|
||||||
|
$got = ""
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$got = Read-Received
|
||||||
|
if ($got -eq "abc") { break }
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
}
|
||||||
|
Write-Host " target received: [$got]"
|
||||||
|
if ($got -ne "abc") {
|
||||||
|
Write-Host " foreground now: $([FgApi]::GetForegroundWindow()) target: $hwnd"
|
||||||
|
throw "FULL-CHAIN FAIL: expected [abc] got [$got]"
|
||||||
|
}
|
||||||
|
Write-Host " PASS: Android key -> v3 Text -> agent KEYEVENTF_UNICODE -> Windows window"
|
||||||
|
|
||||||
|
Write-Host "=== 4. phone ENTER (66): control key uses the scancode channel ==="
|
||||||
|
# Enter carries no printable unicodeChar, so it must go down the WinScancode
|
||||||
|
# path. Observable acceptance: session survives and keeps streaming (a
|
||||||
|
# single-line TextBox swallows Enter, so glyph-level proof is not possible).
|
||||||
|
Assert-Foreground $hwnd "before ENTER"
|
||||||
|
[void](Adb-Shell @("input", "keyevent", "66"))
|
||||||
|
Start-Sleep -Milliseconds 900
|
||||||
|
$xmlRef = ""
|
||||||
|
if (-not (Wait-For-LiveSession 6 ([ref]$xmlRef))) { throw "session died after ENTER" }
|
||||||
|
Write-Host " session alive: $([regex]::Match($xmlRef,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
|
||||||
|
|
||||||
|
Write-Host "=== 5. phone BACK (4): disconnect and return to form ==="
|
||||||
|
[void](Adb-Shell @("input", "keyevent", "4"))
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$xml2 = Get-PhoneUiText
|
||||||
|
if ($xml2 -match "RTT=") { throw "BACK did not disconnect (still live session)" }
|
||||||
|
if ($xml2 -notmatch "android\.widget\.EditText") { throw "BACK did not return to the form" }
|
||||||
|
Write-Host " PASS: disconnected, form visible again"
|
||||||
|
|
||||||
|
Write-Host "=== 6. connect again FROM THE FORM (V4 through real UI, not intent) ==="
|
||||||
|
# Form typing uses keyevent, not 'adb input text': with the Chinese IME active,
|
||||||
|
# text injection gets composed (ASCII '.' becomes U+3002). We disable the IME,
|
||||||
|
# restore it in finally, and never assume a Chinese IME is absent.
|
||||||
|
$origIme = (Adb-Shell @("settings", "get", "secure", "default_input_method")).out.Trim()
|
||||||
|
Write-Host " original IME: [$origIme]"
|
||||||
|
if ($origIme -and $origIme -ne "null") { [void](Adb-Shell @("ime", "disable", $origIme)) }
|
||||||
|
try {
|
||||||
|
$xml3 = Get-PhoneUiText
|
||||||
|
$fields = [regex]::Matches($xml3, '<node[^>]*class="android\.widget\.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if ($fields.Count -lt 3) { throw "expected 3 form fields, found $($fields.Count)" }
|
||||||
|
function Center($m) {
|
||||||
|
$a = [int]$m.Groups[1].Value; $b = [int]$m.Groups[2].Value
|
||||||
|
$c = [int]$m.Groups[3].Value; $d = [int]$m.Groups[4].Value
|
||||||
|
return @(([int](($a + $c) / 2)), ([int](($b + $d) / 2)))
|
||||||
|
}
|
||||||
|
$ipC = Center $fields[0]; $codeC = Center $fields[2]
|
||||||
|
# KEYCODE_0=7..KEYCODE_9=16, PERIOD=56, DEL=67, T=48 (A=29 -> T=29+19;
|
||||||
|
# all from javap against android-34, not memory)
|
||||||
|
[void](Adb-Shell @("input", "tap", "$($ipC[0])", "$($ipC[1])"))
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
for ($i = 0; $i -lt 24; $i++) { [void](Adb-Shell @("input", "keyevent", "67")) }
|
||||||
|
# Type the SAME host this run was given. A hardcoded literal broke this
|
||||||
|
# proof on 2026-10-31 when the dev box's LAN address changed
|
||||||
|
# (192.168.1.180 -> 192.168.1.110): the form dialed the stale address,
|
||||||
|
# timed out, and it looked like a product failure when the harness was
|
||||||
|
# simply out of date. Digits are KEYCODE_0..9 = 7..16, '.' = 56.
|
||||||
|
$ipKeys = @()
|
||||||
|
foreach ($ch in $PhoneIp.ToCharArray()) {
|
||||||
|
if ($ch -eq '.') { $ipKeys += 56 }
|
||||||
|
elseif ($ch -ge '0' -and $ch -le '9') { $ipKeys += (7 + [int][string]$ch) }
|
||||||
|
else { throw "unsupported character in PhoneIp: $ch" }
|
||||||
|
}
|
||||||
|
foreach ($k in $ipKeys) { [void](Adb-Shell @("input", "keyevent", "$k")) }
|
||||||
|
[void](Adb-Shell @("input", "tap", "$($codeC[0])", "$($codeC[1])"))
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
for ($i = 0; $i -lt 6; $i++) { [void](Adb-Shell @("input", "keyevent", "67")) }
|
||||||
|
[void](Adb-Shell @("input", "keyevent", "48"))
|
||||||
|
$xml3 = Get-PhoneUiText
|
||||||
|
# Attribute order in uiautomator dumps is text= ... class= ... bounds=;
|
||||||
|
# matching "class=...text=" silently finds nothing (seen 2026-10-30: printed
|
||||||
|
# an empty form line). Parse per-node and pull attributes independently.
|
||||||
|
$vals = @()
|
||||||
|
foreach ($n in [regex]::Matches($xml3, '<node[^>]*>')) {
|
||||||
|
$v = $n.Value
|
||||||
|
if ($v -match 'class="android\.widget\.EditText"') {
|
||||||
|
$vals += [regex]::Match($v, 'text="([^"]*)"').Groups[1].Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# NOTE: an EMPTY EditText reports its hint as text (verified on device), so
|
||||||
|
# "field non-empty" is NOT a usable judgement - the assertion is always
|
||||||
|
# "field text == typed value". (ASCII-only comments: PS 5.1 decodes
|
||||||
|
# BOM-less .ps1 as GBK.)
|
||||||
|
Write-Host " form now: $($vals -join ' | ')"
|
||||||
|
if ($vals.Count -lt 3) { throw "expected 3 fields after typing, got $($vals.Count)" }
|
||||||
|
if ($vals[0] -ne $PhoneIp) { throw "V4 FAIL: IP field is [$($vals[0])], expected [$PhoneIp]" }
|
||||||
|
$btn = [regex]::Match($xml3, '<node[^>]*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if (-not $btn.Success) { throw "connect button not found" }
|
||||||
|
$bc = Center $btn
|
||||||
|
Assert-Foreground $hwnd "before form connect"
|
||||||
|
[void](Adb-Shell @("input", "tap", "$($bc[0])", "$($bc[1])"))
|
||||||
|
$xmlRef = ""
|
||||||
|
if (-not (Wait-For-LiveSession 25 ([ref]$xmlRef))) {
|
||||||
|
$shown = [regex]::Matches($xmlRef, 'text="([^"]+)"') | ForEach-Object { $_.Groups[1].Value }
|
||||||
|
throw "V4 FAIL: form connect did not reach a live session. screen=[$($shown -join ' | ')]"
|
||||||
|
}
|
||||||
|
Write-Host " V4 PASS via form UI: $([regex]::Match($xmlRef,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($origIme -and $origIme -ne "null") {
|
||||||
|
[void](Adb-Shell @("ime", "enable", $origIme))
|
||||||
|
[void](Adb-Shell @("ime", "set", $origIme))
|
||||||
|
Write-Host " IME restored: $((Adb-Shell @("settings", "get", "secure", "default_input_method")).out.Trim())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 7. stop target ==="
|
||||||
|
New-Item -ItemType File -Force -Path $stopFile | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
Write-Host "=== PHONE CHAIN PROOF COMPLETE ==="
|
||||||
349
tools/proof_common.ps1
Normal file
349
tools/proof_common.ps1
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
# proof_common.ps1 - shared device-side proof helpers (4b, personal docs loop-4b S6).
|
||||||
|
#
|
||||||
|
# ASCII-only file (PS 5.1 reads BOM-less .ps1 as GBK; repo lessons).
|
||||||
|
# Dot-source it: . "$PSScriptRoot\proof_common.ps1"
|
||||||
|
#
|
||||||
|
# Provenance: these are the battle-tested helpers from landscape_proof.ps1 (4a:
|
||||||
|
# wake-only-from-proven-hidden, positive-pair hide check, N25-style unique temp
|
||||||
|
# files, uiautomator empty-dump retry) plus rel_mouse_phone_proof's focus-assert
|
||||||
|
# field clearing. Older proofs are NOT migrated to this file in loop 4b (changing
|
||||||
|
# four validated scripts without re-running all four is itself a false-green
|
||||||
|
# risk) - migration is registered as a harness hygiene item for the next loop.
|
||||||
|
|
||||||
|
# ---- adb wrappers (never let stderr trip ErrorActionPreference=Stop) ----
|
||||||
|
if (-not (Get-Variable -Name PccAdb -Scope Global -ErrorAction SilentlyContinue)) {
|
||||||
|
$global:PccAdb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Adb {
|
||||||
|
param([Parameter(Mandatory)][string[]]$AdbArgs)
|
||||||
|
$old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
$out = & $global:PccAdb @AdbArgs 2>$null
|
||||||
|
@{ out = ($out -join "`n"); code = $LASTEXITCODE }
|
||||||
|
} finally { $ErrorActionPreference = $old }
|
||||||
|
}
|
||||||
|
function AdbShell { param([string[]]$Cmd) (Adb -AdbArgs (@("shell") + $Cmd)).out }
|
||||||
|
|
||||||
|
# ---- UI tree ----
|
||||||
|
# Unique temp per process (N25 lesson applied to dump pulls, cheap insurance).
|
||||||
|
$script:PccUiTmp = "$env:TEMP\pcc_ui_$PID.xml"
|
||||||
|
$script:PccDevDump = "/sdcard/pcc_$PID.xml"
|
||||||
|
|
||||||
|
function Get-Ui {
|
||||||
|
[void](AdbShell @("uiautomator", "dump", $script:PccDevDump))
|
||||||
|
$r = Adb -AdbArgs @("pull", $script:PccDevDump, $script:PccUiTmp)
|
||||||
|
if ($r.code -ne 0 -or -not (Test-Path $script:PccUiTmp)) { return "" }
|
||||||
|
return [System.IO.File]::ReadAllText($script:PccUiTmp, [System.Text.Encoding]::UTF8)
|
||||||
|
}
|
||||||
|
# uiautomator intermittently dumps empty (measured repeatedly in 4a). Retry until
|
||||||
|
# the tree is plausibly real. Callers still assert on their own node - this only
|
||||||
|
# removes the transient, never masks a genuine absence.
|
||||||
|
function Get-UiRetry([int]$tries = 4) {
|
||||||
|
$x = ""
|
||||||
|
for ($i = 0; $i -lt $tries; $i++) {
|
||||||
|
$x = Get-Ui
|
||||||
|
if ($x.Length -gt 500) { return $x }
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
}
|
||||||
|
Write-Host " [ui] retry exhausted, last dump len=$($x.Length)"
|
||||||
|
return $x
|
||||||
|
}
|
||||||
|
function VisibleTexts([string]$xml) {
|
||||||
|
[regex]::Matches($xml, 'text="([^"]+)"') | ForEach-Object {
|
||||||
|
[System.Net.WebUtility]::HtmlDecode($_.Groups[1].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- buttons: match by LABEL, never by position (3b/N23 lesson) ----
|
||||||
|
function ButtonNode([string]$xml, [string]$want, [bool]$prefix = $false) {
|
||||||
|
foreach ($n in [regex]::Matches($xml, '<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||||
|
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value, 'text="([^"]*)"').Groups[1].Value)
|
||||||
|
$hit = if ($prefix) { $t.StartsWith($want) } else { $t -eq $want }
|
||||||
|
if ($hit) { return $n.Value }
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
function ButtonCenter([string]$xml, [string]$want, [bool]$prefix = $false) {
|
||||||
|
$v = ButtonNode $xml $want $prefix
|
||||||
|
if (-not $v) { return $null }
|
||||||
|
$b = [regex]::Match($v, 'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if (-not $b.Success) { return $null }
|
||||||
|
@{ cx = [int](([int]$b.Groups[1].Value + [int]$b.Groups[3].Value) / 2)
|
||||||
|
cy = [int](([int]$b.Groups[2].Value + [int]$b.Groups[4].Value) / 2) }
|
||||||
|
}
|
||||||
|
function ButtonLabelByPrefix([string]$xml, [string]$prefix) {
|
||||||
|
foreach ($n in [regex]::Matches($xml, '<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||||
|
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value, 'text="([^"]*)"').Groups[1].Value)
|
||||||
|
if ($t.StartsWith($prefix)) { return $t }
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 4a observation-surface discipline: controls auto-hide in landscape ----
|
||||||
|
# Hidden state = POSITIVE PAIR (video View node present AND zero Button nodes).
|
||||||
|
# An empty dump would otherwise pass "no buttons" trivially (N24 false-fails).
|
||||||
|
function ControlsHidden([string]$ui) {
|
||||||
|
$anyBtn = ($ui -match 'class="android\.widget\.Button"')
|
||||||
|
$video = ($ui -match 'class="android\.view\.View"')
|
||||||
|
return ($video -and -not $anyBtn)
|
||||||
|
}
|
||||||
|
# Wake tap lives in the letterbox black bar (x=100): outside the video in both
|
||||||
|
# states, consumed by the app only when hidden - zero remote leakage (4a/D1).
|
||||||
|
function Wake-Band {
|
||||||
|
$d = LocalDisplay
|
||||||
|
[void](AdbShell @("input", "tap", "100", "$([int]($d.h / 2))"))
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
}
|
||||||
|
function Find-VisibleButton([string]$want, [bool]$prefix = $false) {
|
||||||
|
for ($try = 0; $try -lt 5; $try++) {
|
||||||
|
$ui = Get-UiRetry
|
||||||
|
$b = ButtonCenter $ui $want $prefix
|
||||||
|
if ($b) { return $b }
|
||||||
|
if (ControlsHidden $ui) { Wake-Band; continue }
|
||||||
|
if ($ui.Length -gt 500) { return $null } # real tree, genuine absence
|
||||||
|
Start-Sleep -Milliseconds 400 # empty-dump transient
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
function Tap-VisibleButton([string]$want, [bool]$prefix = $false) {
|
||||||
|
$b = Find-VisibleButton $want $prefix
|
||||||
|
if (-not $b) { return $null }
|
||||||
|
[void](AdbShell @("input", "tap", "$($b.cx)", "$($b.cy)"))
|
||||||
|
return $b
|
||||||
|
}
|
||||||
|
function Read-ButtonLabel([string]$want, [bool]$prefix = $false) {
|
||||||
|
for ($try = 0; $try -lt 6; $try++) {
|
||||||
|
$ui = Get-UiRetry
|
||||||
|
$l = ButtonLabelByPrefix $ui $want
|
||||||
|
if ($l) { return $l }
|
||||||
|
if (ControlsHidden $ui) { Wake-Band; continue }
|
||||||
|
if ($ui.Length -gt 500) { return $null }
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- HUD / session liveness ----
|
||||||
|
# "zhen=" frame counter (char U+5E27 as code point, ASCII-safe source).
|
||||||
|
function FrameCount {
|
||||||
|
$m = [regex]::Match((Get-Ui), ([char]0x5E27 + '=(\d+)'))
|
||||||
|
if ($m.Success) { return [long]$m.Groups[1].Value }
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
function WakeControls {
|
||||||
|
$d = LocalDisplay
|
||||||
|
[void](AdbShell @("input", "tap", "100", "$([int]($d.h / 2))"))
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
function FrameCountOrWake {
|
||||||
|
$f = FrameCount
|
||||||
|
if ($f -ge 0) { return $f }
|
||||||
|
WakeControls
|
||||||
|
return FrameCount
|
||||||
|
}
|
||||||
|
# Live HUD line (RTT= only exists when connected; hidden-controls caveat above).
|
||||||
|
function Wait-LiveHud([int]$seconds = 20) {
|
||||||
|
$deadline = (Get-Date).AddSeconds($seconds)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$ui = Get-Ui
|
||||||
|
if ($ui -notmatch "RTT=") {
|
||||||
|
if (ControlsHidden $ui) { WakeControls }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$m = [regex]::Match($ui, 'text="([^"]*RTT=[^"]*)"')
|
||||||
|
if ($m.Success) { return [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value) }
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- display / app lifecycle ----
|
||||||
|
# vivo ROM prints NO mRotation= line and keeps a permanent 1080x2400 virtual
|
||||||
|
# display (N30): orientation MUST be read from cur= inside the mDisplayId=0 block.
|
||||||
|
function LocalDisplay {
|
||||||
|
$d = (AdbShell @("dumpsys", "window", "displays"))
|
||||||
|
$idx = $d.IndexOf("Display: mDisplayId=0")
|
||||||
|
if ($idx -lt 0) { return @{ w = 0; h = 0 } }
|
||||||
|
$m = [regex]::Match($d.Substring($idx), 'cur=(\d+)x(\d+)')
|
||||||
|
if (-not $m.Success) { return @{ w = 0; h = 0 } }
|
||||||
|
@{ w = [int]$m.Groups[1].Value; h = [int]$m.Groups[2].Value }
|
||||||
|
}
|
||||||
|
function Wake {
|
||||||
|
[void](AdbShell @("input", "keyevent", "224"))
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
[void](AdbShell @("wm", "dismiss-keyguard"))
|
||||||
|
Start-Sleep -Milliseconds 350
|
||||||
|
}
|
||||||
|
function Restart-AppForm {
|
||||||
|
Wake
|
||||||
|
[void](AdbShell @("am", "force-stop", "com.verify.remote"))
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
[void](AdbShell @("am", "start", "-n", "com.verify.remote/.MainActivity"))
|
||||||
|
$deadline = (Get-Date).AddSeconds(8)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$x = Get-Ui
|
||||||
|
if (([regex]::Matches($x, 'class="android\.widget\.EditText"')).Count -ge 3) { return $x }
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
}
|
||||||
|
throw "form did not appear: [$(VisibleTexts (Get-Ui) -join ' || ')]"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- connect form: focus-assert typing (rel_mouse_proof grade, R-8) ----
|
||||||
|
function CenterOf($m) {
|
||||||
|
@([int](([int]$m.Groups[1].Value + [int]$m.Groups[3].Value) / 2),
|
||||||
|
[int](([int]$m.Groups[2].Value + [int]$m.Groups[4].Value) / 2))
|
||||||
|
}
|
||||||
|
function FormFields([string]$xml) {
|
||||||
|
[regex]::Matches($xml, '<node[^>]*class="android\.widget\.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
}
|
||||||
|
function TypeAscii([string]$s) {
|
||||||
|
foreach ($ch in $s.ToUpper().ToCharArray()) {
|
||||||
|
$k = if ($ch -ge '0' -and $ch -le '9') { 7 + [int][string]$ch }
|
||||||
|
elseif ($ch -ge 'A' -and $ch -le 'Z') { 29 + ([int]$ch - [int][char]'A') }
|
||||||
|
elseif ($ch -eq '.') { 56 } else { throw "unsupported char $ch" }
|
||||||
|
[void](AdbShell @("input", "keyevent", "$k"))
|
||||||
|
Start-Sleep -Milliseconds 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function FocusedIndex([string]$xml) {
|
||||||
|
$i = -1
|
||||||
|
foreach ($n in [regex]::Matches($xml, '<node[^>]*>')) {
|
||||||
|
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
||||||
|
$i++
|
||||||
|
if ($n.Value -match 'focused="true"') { return $i }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
function Clear-Field($c, [int]$wantIdx) {
|
||||||
|
for ($try = 0; $try -lt 3; $try++) {
|
||||||
|
[void](AdbShell @("input", "tap", "$($c[0])", "$($c[1])"))
|
||||||
|
$deadline = (Get-Date).AddSeconds(1.5)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
if ((FocusedIndex (Get-Ui)) -eq $wantIdx) {
|
||||||
|
for ($i = 0; $i -lt 40; $i++) { [void](AdbShell @("input", "keyevent", "67")) }
|
||||||
|
Start-Sleep -Milliseconds 120
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw "field $wantIdx never took focus"
|
||||||
|
}
|
||||||
|
function FillAndConnect([string]$xml, [string]$host_, [string]$port_, [string]$code_,
|
||||||
|
[string]$fConnect, [string]$fReconnect) {
|
||||||
|
$f = FormFields $xml
|
||||||
|
if ($f.Count -lt 3) { throw "form has $($f.Count) fields" }
|
||||||
|
Clear-Field (CenterOf $f[0]) 0; TypeAscii $host_
|
||||||
|
Clear-Field (CenterOf $f[1]) 1; TypeAscii $port_
|
||||||
|
Clear-Field (CenterOf $f[2]) 2; TypeAscii $code_
|
||||||
|
$expect = @($host_, $port_, $code_)
|
||||||
|
$vals = @()
|
||||||
|
for ($retry = 0; $retry -le 2; $retry++) {
|
||||||
|
$x2 = Get-UiRetry
|
||||||
|
$vals = @()
|
||||||
|
foreach ($n in [regex]::Matches($x2, '<node[^>]*>')) {
|
||||||
|
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
||||||
|
$vals += [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value, 'text="([^"]*)"').Groups[1].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($vals.Count -lt 3) { throw "expected 3 fields after typing, got $($vals.Count)" }
|
||||||
|
$bad = -1
|
||||||
|
for ($i = 0; $i -lt 3; $i++) { if ($vals[$i] -ne $expect[$i]) { $bad = $i; break } }
|
||||||
|
if ($bad -ge 0) {
|
||||||
|
$f = FormFields $x2
|
||||||
|
Clear-Field (CenterOf $f[$bad]) $bad
|
||||||
|
TypeAscii $expect[$bad]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$btn = ButtonCenter $x2 $fConnect
|
||||||
|
if (-not $btn) { $btn = ButtonCenter $x2 $fReconnect }
|
||||||
|
if (-not $btn) { $btn = Find-VisibleButton $fConnect
|
||||||
|
if (-not $btn) { $btn = Find-VisibleButton $fReconnect } }
|
||||||
|
if (-not $btn) { throw "connect button not found" }
|
||||||
|
[void](AdbShell @("input", "tap", "$($btn.cx)", "$($btn.cy)"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw "form input never converged: [$($vals -join ' | ')]"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- video geometry (uiautomator has no SurfaceView - match 16:9 View, N21) ----
|
||||||
|
function VideoBounds([string]$xml) {
|
||||||
|
$best = $null; $bestErr = 1e9
|
||||||
|
foreach ($n in [regex]::Matches($xml, '<node[^>]*class="android\.view\.View"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')) {
|
||||||
|
$x1 = [int]$n.Groups[1].Value; $y1 = [int]$n.Groups[2].Value
|
||||||
|
$x2 = [int]$n.Groups[3].Value; $y2 = [int]$n.Groups[4].Value
|
||||||
|
$w = $x2 - $x1; $h = $y2 - $y1
|
||||||
|
if ($w -lt 300 -or $h -lt 100) { continue }
|
||||||
|
$err = [Math]::Abs($w / [double]$h - 16.0 / 9.0)
|
||||||
|
if ($err -lt $bestErr) { $bestErr = $err; $best = @{ l = $x1; t = $y1; w = $w; h = $h } }
|
||||||
|
}
|
||||||
|
if ($best) { $best.err = $bestErr }
|
||||||
|
return $best
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- PC-side cursor: DPI-aware set with hijack gates ----
|
||||||
|
if (-not ('PccCur' -as [type])) {
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System; using System.Runtime.InteropServices;
|
||||||
|
public class PccCur {
|
||||||
|
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
|
||||||
|
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr c);
|
||||||
|
public static void Dpi(){ SetProcessDpiAwarenessContext(new IntPtr(-4)); }
|
||||||
|
public static int[] Get(){ POINT p; GetCursorPos(out p); return new int[]{p.X,p.Y}; }
|
||||||
|
public static void Set(int x,int y){ SetCursorPos(x,y); }
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
[PccCur]::Dpi()
|
||||||
|
|
||||||
|
# SetCursorQuiet: the machine may be shared (2026-11-02 probe: a human used the
|
||||||
|
# real mouse mid-probe and faked every reading). Gate: two reads 350ms apart must
|
||||||
|
# agree (nobody is moving), then Set + read-back must equal the request; retries
|
||||||
|
# on hijack; loud failure instead of a silently wrong sample (R-8).
|
||||||
|
function Set-CursorQuiet([int]$x, [int]$y, [int]$tries = 8) {
|
||||||
|
for ($i = 0; $i -lt $tries; $i++) {
|
||||||
|
$a = [PccCur]::Get()
|
||||||
|
Start-Sleep -Milliseconds 350
|
||||||
|
$b = [PccCur]::Get()
|
||||||
|
if (-not ($a[0] -eq $b[0] -and $a[1] -eq $b[1])) {
|
||||||
|
Write-Host " [cursor] busy ($($a -join ',')->$($b -join ',')), retry $($i+1)"
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
[PccCur]::Set($x, $y)
|
||||||
|
Start-Sleep -Milliseconds 120
|
||||||
|
$g = [PccCur]::Get()
|
||||||
|
if ($g[0] -eq $x -and $g[1] -eq $y) { return }
|
||||||
|
Write-Host " [cursor] readback ($($g -join ',')) != ($x,$y), retry $($i+1)"
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
throw "cursor never settled at ($x,$y) - someone is using the real mouse; retry the proof when the PC is idle"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- memory precheck (N20/N29: OOM fakes build/test failures) ----
|
||||||
|
function Memory-Precheck([double]$minGB = 0.5) {
|
||||||
|
$os = Get-CimInstance Win32_OperatingSystem
|
||||||
|
$freeGB = $os.FreeVirtualMemory / 1MB
|
||||||
|
Write-Host (" commit free: {0:N1} GB" -f $freeGB)
|
||||||
|
if ($freeGB -lt $minGB) { throw "commit memory critically low - free RAM first (gradle --stop)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- R-7 stale-agent guard: binary newer than process => restart ----
|
||||||
|
function Ensure-FreshAgent([string]$AgentExe, [int]$Port, [string]$Code) {
|
||||||
|
if (-not (Test-Path $AgentExe)) { throw "missing $AgentExe (cargo build --release -p agent)" }
|
||||||
|
$ag = @(Get-Process agent -ErrorAction SilentlyContinue)
|
||||||
|
if ($ag.Count -gt 0 -and ((Get-Item $AgentExe).LastWriteTime -gt $ag[0].StartTime)) {
|
||||||
|
Write-Host " agent predates build; restarting"
|
||||||
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 800; $ag = @()
|
||||||
|
}
|
||||||
|
if ($ag.Count -eq 0) {
|
||||||
|
Start-Process $AgentExe -ArgumentList "--port", "$Port", "--code", $Code -WindowStyle Hidden | Out-Null
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Write-Host " agent started (fresh)"
|
||||||
|
} else { Write-Host " agent running and current" }
|
||||||
|
}
|
||||||
365
tools/punch_probe.py
Normal file
365
tools/punch_probe.py
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""UDP 打洞探针 —— 两台不同网络的机器各跑一个,用来摸清你们线路的底牌。
|
||||||
|
|
||||||
|
回答四个问题:
|
||||||
|
1. 这条线路的 NAT 能不能打洞?
|
||||||
|
2. 要几轮才能打通?(轮数越少说明 NAT 越宽松)
|
||||||
|
3. 对方的源端口有没有被 NAT 改写?(判断是不是对称 NAT)
|
||||||
|
4. 有没有公网 IPv6?IPv6 直连通不通?
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
A 机(假设公网 1.2.3.4):
|
||||||
|
py punch_probe.py --peer 2.3.4.5:47800
|
||||||
|
|
||||||
|
B 机(假设公网 2.3.4.5):
|
||||||
|
py punch_probe.py --peer 1.2.3.4:47800
|
||||||
|
|
||||||
|
IPv6(双方都有公网 v6 时):
|
||||||
|
py punch_probe.py --peer [2408:8207:1234::1]:47800
|
||||||
|
|
||||||
|
先查自己的公网映射(走 STUN,不需要对方):
|
||||||
|
py punch_probe.py --stun
|
||||||
|
|
||||||
|
被动监听(不发包,只看能不能收到对方打进来的包):
|
||||||
|
py punch_probe.py
|
||||||
|
|
||||||
|
说明
|
||||||
|
----
|
||||||
|
- 两台机器必须**同时**启动,脚本靠「双方同时向对方发包」来穿透 NAT。
|
||||||
|
- 先各自查公网 IP(浏览器搜 "my ip" 或 --stun),再互相填。
|
||||||
|
- 只依赖 Python 标准库。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
MAGIC = b"PUNCHv1|"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 地址解析
|
||||||
|
|
||||||
|
def parse_addr(s):
|
||||||
|
"""解析 'ip:port' 或 '[v6]:port' -> (host, port, family)"""
|
||||||
|
s = s.strip()
|
||||||
|
if s.startswith("["):
|
||||||
|
end = s.index("]")
|
||||||
|
host = s[1:end]
|
||||||
|
port = int(s[end + 2:])
|
||||||
|
return host, port, socket.AF_INET6
|
||||||
|
host, _, p = s.rpartition(":")
|
||||||
|
return host, int(p), socket.AF_INET
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_addr(sockaddr):
|
||||||
|
host, port = sockaddr[0], sockaddr[1]
|
||||||
|
if ":" in host:
|
||||||
|
return "[%s]:%d" % (host, port)
|
||||||
|
return "%s:%d" % (host, port)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- STUN
|
||||||
|
|
||||||
|
def stun_mapped(server="stun.l.google.com", port=19302, timeout=3.0, sock=None):
|
||||||
|
"""最小 STUN Binding 请求,返回 (ip, port);失败返回 None。
|
||||||
|
|
||||||
|
sock 不为 None 时复用它(用来测「同一个本地端口对另一个目标」的映射端口,
|
||||||
|
这是判断 NAT 是不是对称型的关键)。
|
||||||
|
"""
|
||||||
|
txid = b"\x5a" * 12
|
||||||
|
req = struct.pack(">HHI12s", 0x0001, 0, 0x2112A442, txid)
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(server, port, 0, socket.SOCK_DGRAM)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
fam, sockaddr = infos[0][0], infos[0][4]
|
||||||
|
own = sock is None
|
||||||
|
s = socket.socket(fam, socket.SOCK_DGRAM) if own else sock
|
||||||
|
s.settimeout(timeout)
|
||||||
|
try:
|
||||||
|
s.sendto(req, sockaddr)
|
||||||
|
data, _ = s.recvfrom(2048)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if own:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
if len(data) < 20:
|
||||||
|
return None
|
||||||
|
off = 20
|
||||||
|
while off + 4 <= len(data):
|
||||||
|
atype, alen = struct.unpack(">HH", data[off:off + 4])
|
||||||
|
val = data[off + 4:off + 4 + alen]
|
||||||
|
if atype == 0x0020 and len(val) >= 8: # XOR-MAPPED-ADDRESS
|
||||||
|
famv = val[1]
|
||||||
|
xport = struct.unpack(">H", val[2:4])[0] ^ 0x2112
|
||||||
|
if famv == 0x01 and len(val) >= 8:
|
||||||
|
ip = bytes(a ^ b for a, b in zip(val[4:8], b"\x21\x12\xA4\x42"))
|
||||||
|
return socket.inet_ntoa(ip), xport
|
||||||
|
if famv == 0x02 and len(val) >= 20:
|
||||||
|
mask = b"\x21\x12\xA4\x42" + txid
|
||||||
|
ip = bytes(a ^ b for a, b in zip(val[4:20], mask))
|
||||||
|
return socket.inet_ntop(socket.AF_INET6, ip), xport
|
||||||
|
off += 4 + alen + ((4 - alen % 4) % 4)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 信令
|
||||||
|
|
||||||
|
def discover_peer(sock, server, room, timeout=60.0):
|
||||||
|
"""注册到房间,等服务器回推对方的公网地址。
|
||||||
|
|
||||||
|
注意:这里**复用同一个 socket**——服务器看到的源地址,就是 NAT 为
|
||||||
|
「本机 → 服务器」这条流分配的映射。对锥形 NAT 而言,它与「本机 → 对端」
|
||||||
|
的映射相同,所以拿它去打洞是有效的;对对称 NAT 则不同,打不通属预期。
|
||||||
|
"""
|
||||||
|
host, port = server[0], server[1]
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
last_send = 0.0
|
||||||
|
while time.time() < deadline:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_send > 1.0:
|
||||||
|
try:
|
||||||
|
sock.sendto(b"REGISTER|" + room.encode("utf-8"), (host, port))
|
||||||
|
except OSError as e:
|
||||||
|
print(" 注册发送失败:%s" % e)
|
||||||
|
return None
|
||||||
|
last_send = now
|
||||||
|
try:
|
||||||
|
data, _src = sock.recvfrom(2048)
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except ConnectionResetError:
|
||||||
|
continue
|
||||||
|
except OSError as e:
|
||||||
|
if getattr(e, "winerror", None) == 10054:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
txt = data.decode("utf-8", "replace").strip()
|
||||||
|
if txt.startswith("PEER|"):
|
||||||
|
h, p, _f = parse_addr(txt[5:])
|
||||||
|
return (h, p)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 主逻辑
|
||||||
|
|
||||||
|
class Probe:
|
||||||
|
def __init__(self, bind_host, port, family):
|
||||||
|
self.sock = socket.socket(family, socket.SOCK_DGRAM)
|
||||||
|
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
if family == socket.AF_INET6:
|
||||||
|
try:
|
||||||
|
self.sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.sock.bind((bind_host, port))
|
||||||
|
self.sock.settimeout(0.2)
|
||||||
|
# Windows 特有坑:曾经发过包给对方,但对方端口没开 → 会收到 ICMP 端口不可达,
|
||||||
|
# 之后下一次 recv 直接抛 WSAECONNRESET(10054)。不打这个 ioctl,
|
||||||
|
# 接收循环会被"自己之前的失败发送"打断,之后再也收不到包。
|
||||||
|
# (Rust 实现同样要处理:tokio recv 返回 ConnectionReset 时不能退出循环)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
self.sock.ioctl(socket.SIO_UDP_CONNRESET, False)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
self.port = self.sock.getsockname()[1]
|
||||||
|
self.stop = threading.Event()
|
||||||
|
self.first_src = None
|
||||||
|
self.packets = 0
|
||||||
|
self.junk = 0
|
||||||
|
|
||||||
|
def receiver(self, ignore=()):
|
||||||
|
"""接收线程。
|
||||||
|
|
||||||
|
只认「以 MAGIC 开头」且「来自对端而非信令服务器」的包。
|
||||||
|
信令服务器的 PEER/WAIT 回复绝不能算作打通——否则客户端反复注册引来的
|
||||||
|
回复会造成假阳性(已踩过:把服务器的 PEER 回复误判成打洞成功)。
|
||||||
|
"""
|
||||||
|
while not self.stop.is_set():
|
||||||
|
try:
|
||||||
|
data, src = self.sock.recvfrom(2048)
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except ConnectionResetError:
|
||||||
|
# ICMP 端口不可达留下的错误,忽略并继续收(绝不能 break)
|
||||||
|
continue
|
||||||
|
except OSError as e:
|
||||||
|
if getattr(e, "winerror", None) == 10054:
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
# ---- 过滤:非打洞包一律丢弃
|
||||||
|
if not data.startswith(MAGIC) or (ignore and src[:2] in ignore):
|
||||||
|
self.junk += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.first_src is None:
|
||||||
|
self.first_src = src
|
||||||
|
print("\n ✅ 收到对方的包!")
|
||||||
|
print(" 对方源地址(NAT 之后)= %s" % fmt_addr(src))
|
||||||
|
if src[1] != self.port:
|
||||||
|
print(" 对方源端口 = %d(本机端口 %d);若双方约定端口相同,"
|
||||||
|
"说明 NAT 改写了端口" % (src[1], self.port))
|
||||||
|
else:
|
||||||
|
print(" 对方源端口与约定端口一致 → NAT 保留了端口")
|
||||||
|
self.packets += 1
|
||||||
|
if self.packets <= 3:
|
||||||
|
print(" [%s] 收到 %d 字节" % (fmt_addr(src), len(data)))
|
||||||
|
|
||||||
|
def send_rounds(self, peer_sockaddr, rounds, interval):
|
||||||
|
for i in range(1, rounds + 1):
|
||||||
|
if self.stop.is_set():
|
||||||
|
return
|
||||||
|
payload = MAGIC + struct.pack(">d", time.time())
|
||||||
|
try:
|
||||||
|
self.sock.sendto(payload, peer_sockaddr)
|
||||||
|
except OSError as e:
|
||||||
|
print(" 发送失败:%s" % e)
|
||||||
|
return
|
||||||
|
if i == 1 or i % 5 == 0 or self.first_src is not None:
|
||||||
|
mark = "✅" if self.first_src is not None else " "
|
||||||
|
print(" %s 第 %2d/%d 轮已发出" % (mark, i, rounds))
|
||||||
|
if self.first_src is not None:
|
||||||
|
return
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(add_help=True, description="UDP 打洞探针")
|
||||||
|
ap.add_argument("--peer", help="对方地址 ip:port 或 [v6]:port;不给则只被动监听")
|
||||||
|
ap.add_argument("--port", type=int, default=47800, help="本地端口(默认 47800)")
|
||||||
|
ap.add_argument("--rounds", type=int, default=25, help="发包轮数(默认 25)")
|
||||||
|
ap.add_argument("--interval", type=float, default=0.2, help="轮间隔秒(默认 0.2)")
|
||||||
|
ap.add_argument("--grace", type=float, default=2.5,
|
||||||
|
help="发完后继续监听多少秒等回包(默认 2.5)")
|
||||||
|
ap.add_argument("--server", help="信令服务器 ip:port(与 --room 配合,自动配对)")
|
||||||
|
ap.add_argument("--room", help="房间名;同一房间的两个客户端会被配对")
|
||||||
|
ap.add_argument("--wait", type=float, default=60.0,
|
||||||
|
help="等对方加入房间的秒数(默认 60)")
|
||||||
|
ap.add_argument("--stun", action="store_true", help="只查询自己的公网映射后退出")
|
||||||
|
ap.add_argument("--stun-port", type=int, default=0,
|
||||||
|
help="配合 --stun:用指定本地端口发 STUN(与打洞端口一致时才能对比映射)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
# ---- 只查 STUN
|
||||||
|
if args.stun:
|
||||||
|
s = None
|
||||||
|
if args.stun_port:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind(("0.0.0.0", args.stun_port))
|
||||||
|
print("[STUN] 用本机端口 %d 查询(与打洞同端口)..." % args.stun_port)
|
||||||
|
else:
|
||||||
|
print("[STUN] 查询公网映射(临时端口)...")
|
||||||
|
r = stun_mapped(sock=s)
|
||||||
|
if s:
|
||||||
|
s.close()
|
||||||
|
if r:
|
||||||
|
print(" 对 Google STUN 的映射 = %s:%d" % r)
|
||||||
|
if args.stun_port:
|
||||||
|
print(" 本机端口 = %d" % args.stun_port)
|
||||||
|
print(" 下一步:对比信令服务器日志里记录的源端口")
|
||||||
|
print(" 两者相同 → 端点无关映射(锥形 NAT)→ 打洞大概率成功")
|
||||||
|
print(" 两者不同 → 对称 NAT → 打洞困难,需要端口预测或中继")
|
||||||
|
else:
|
||||||
|
print(" 查询失败(STUN 19302 可能被封;可稍后重试)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ---- 决定地址族与模式
|
||||||
|
peer = None
|
||||||
|
if args.peer:
|
||||||
|
host, pport, family = parse_addr(args.peer)
|
||||||
|
peer = (host, pport)
|
||||||
|
bind_host = "::" if family == socket.AF_INET6 else "0.0.0.0"
|
||||||
|
mode = "双向打洞(手工填地址)"
|
||||||
|
elif args.server and args.room:
|
||||||
|
_h, _p, family = parse_addr(args.server)
|
||||||
|
bind_host = "::" if family == socket.AF_INET6 else "0.0.0.0"
|
||||||
|
mode = "信令自动配对 + 打洞"
|
||||||
|
else:
|
||||||
|
family = socket.AF_INET
|
||||||
|
bind_host = "0.0.0.0"
|
||||||
|
mode = "被动监听"
|
||||||
|
|
||||||
|
probe = Probe(bind_host, args.port, family)
|
||||||
|
print("=" * 60)
|
||||||
|
print(" UDP 打洞探针 模式=%s" % mode)
|
||||||
|
print(" 本机监听 %s:%d" % (bind_host, probe.port))
|
||||||
|
if peer:
|
||||||
|
print(" 发往 %s (%s)" % (fmt_addr(peer + (0, 0)),
|
||||||
|
"IPv6" if family == socket.AF_INET6 else "IPv4"))
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# ---- 信令配对:不用自己查公网 IP,服务器看到的就是你的公网映射
|
||||||
|
if args.server and args.room and peer is None:
|
||||||
|
srv = parse_addr(args.server)[:2]
|
||||||
|
print("\n向信令服务器 %s 注册 room=%s ..." % (fmt_addr(srv), args.room))
|
||||||
|
found = discover_peer(probe.sock, srv, args.room, timeout=args.wait)
|
||||||
|
if found is None:
|
||||||
|
print(" ❌ 超时:没等到对方加入同一个房间")
|
||||||
|
probe.sock.close()
|
||||||
|
return 1
|
||||||
|
peer = found
|
||||||
|
print(" ✅ 配对成功,对方公网地址 = %s" % fmt_addr(found))
|
||||||
|
|
||||||
|
# 信令服务器的回复(PEER/WAIT)绝不能算作打通,接收线程必须把它排除掉
|
||||||
|
ignore = set()
|
||||||
|
if args.server:
|
||||||
|
_sh, _sp, _sf = parse_addr(args.server)
|
||||||
|
ignore.add((_sh, _sp))
|
||||||
|
|
||||||
|
t = threading.Thread(target=probe.receiver, args=(ignore,), daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if peer:
|
||||||
|
print("\n开始同时发包(若对方也在发包,第 1~3 轮通常就通)...")
|
||||||
|
probe.send_rounds(peer, args.rounds, args.interval)
|
||||||
|
# 对方可能比我晚启动:发完后继续监听,否则会把"晚到的成功"误判成失败
|
||||||
|
if probe.first_src is None:
|
||||||
|
print("\n发包结束,继续监听 %.1f 秒等对方回包 ..." % args.grace)
|
||||||
|
waited = 0.0
|
||||||
|
while waited < args.grace and probe.first_src is None:
|
||||||
|
time.sleep(0.1)
|
||||||
|
waited += 0.1
|
||||||
|
else:
|
||||||
|
print("\n被动监听中,Ctrl+C 退出 ...")
|
||||||
|
while not probe.stop.is_set():
|
||||||
|
time.sleep(0.3)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
probe.stop.set()
|
||||||
|
time.sleep(0.3)
|
||||||
|
probe.sock.close()
|
||||||
|
|
||||||
|
print("\n" + "-" * 60)
|
||||||
|
if probe.first_src is not None:
|
||||||
|
print("结论:✅ 打洞成功")
|
||||||
|
print(" 对方实际源地址 = %s" % fmt_addr(probe.first_src))
|
||||||
|
print(" 共收到 %d 个包" % probe.packets)
|
||||||
|
else:
|
||||||
|
print("结论:❌ 本轮没有收到对方的打洞包")
|
||||||
|
if probe.junk:
|
||||||
|
print(" (另外收到 %d 个非打洞包并已忽略:那是信令服务器的回复,不算打通)"
|
||||||
|
% probe.junk)
|
||||||
|
print(" 可能原因(按概率排序):")
|
||||||
|
print(" 1. 一端或两端是对称型 NAT / 运营商级 NAT(CGNAT) → 需要端口预测或中继")
|
||||||
|
print(" 2. 一方根本没发包(对方没启动、或地址填错/端口填错)")
|
||||||
|
print(" 3. UDP 被防火墙拦截(企业网络常见)")
|
||||||
|
print(" 4. 双方没有真正同时发包(时间差超过 NAT 表项寿命)")
|
||||||
|
print(" 下一步:两端各跑一次 --stun,看看是不是落在 100.64~100.127 网段(CGNAT)")
|
||||||
|
print("-" * 60)
|
||||||
|
return 0 if probe.first_src is not None else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
698
tools/rel_mouse_phone_proof.ps1
Normal file
698
tools/rel_mouse_phone_proof.ps1
Normal file
@ -0,0 +1,698 @@
|
|||||||
|
# 3b phone-side proof: relative mouse (protocol v4) + long-press right click + R7 regression.
|
||||||
|
#
|
||||||
|
# ASCII-only source (PS 5.1 decodes BOM-less .ps1 as GBK; see personal lessons).
|
||||||
|
# Expected Chinese UI strings are matched as CODE-POINT fragments (R-8: assert the
|
||||||
|
# actual value, and print what the parser really read so an empty result cannot pass).
|
||||||
|
#
|
||||||
|
# Judgements:
|
||||||
|
# V-B1a relative mode moves the PC pointer by the expected amount (direction+sign)
|
||||||
|
# V-B2 absolute mode still maps finger -> desktop 1:1 (R7 re-run, <=8px error)
|
||||||
|
# V-B3 long press (>=500ms, still) opens a context menu; a drag does NOT
|
||||||
|
# V-B7 R7 + Chinese IME + full chain regressions stay green
|
||||||
|
#
|
||||||
|
# Prereqs: vivo device online via adb, 3b APK installed, v4 agent running.
|
||||||
|
# powershell -ExecutionPolicy Bypass -File tools\rel_mouse_phone_proof.ps1 -Host_ 192.168.1.110
|
||||||
|
param(
|
||||||
|
[string]$Host_ = "192.168.1.110",
|
||||||
|
[int]$Port = 47800,
|
||||||
|
[string]$Code = "t",
|
||||||
|
[string]$AgentExe = ""
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = (Resolve-Path "$PSScriptRoot\..").Path
|
||||||
|
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||||
|
$ev = Join-Path (Split-Path $repo -Parent) "evidence\3b"
|
||||||
|
New-Item -ItemType Directory -Force -Path $ev | Out-Null
|
||||||
|
|
||||||
|
# ---- code-point fragments (grep-verified present in the Kotlin sources) ----
|
||||||
|
$F_CONNECT = -join ([char]0x8FDE,[char]0x63A5) # "connect"
|
||||||
|
$F_RECONNECT = -join ([char]0x91CD,[char]0x65B0,[char]0x8FDE,[char]0x63A5) # "reconnect"
|
||||||
|
$F_RELATIVE = -join ([char]0x76F8,[char]0x5BF9) # "relative"
|
||||||
|
$F_ABSOLUTE = -join ([char]0x7EDD,[char]0x5BF9) # "absolute"
|
||||||
|
$F_MODE_TAG = -join ([char]0x6A21,[char]0x5F0F) # "mode"
|
||||||
|
$F_LONGPRESS = -join ([char]0x957F,[char]0x6309,[char]0x53F3,[char]0x952E) # "long-press right click"
|
||||||
|
$F_RCLICK_TAG = -join ([char]0x53F3,[char]0x952E,[char]0x3D) # "right-click="
|
||||||
|
$F_CLICK = -join ([char]0x5355,[char]0x51FB) # "single click"
|
||||||
|
|
||||||
|
# Read the persistent right-click counter from the HUD ("right-click=N(source)").
|
||||||
|
# Returns -1 when absent. A counter is used instead of the one-line debug text
|
||||||
|
# because ACTION_UP immediately overwrites the debug line after a long press
|
||||||
|
# (measured 2026-10-31: screen showed the long-press-END label, the firing evidence was gone).
|
||||||
|
function RightClickCount {
|
||||||
|
for ($t=0; $t -lt 3; $t++) {
|
||||||
|
$x = Get-UiText
|
||||||
|
$m = [regex]::Match($x, 'text="([^"]*RTT=[^"]*)"')
|
||||||
|
if ($m.Success) {
|
||||||
|
$hud = [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value)
|
||||||
|
$c = [regex]::Match($hud, "$F_RCLICK_TAG(\d+)")
|
||||||
|
if ($c.Success) { return [int]$c.Groups[1].Value }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
# 4a: in landscape the HUD hides WITH the controls (GONE, not empty text)
|
||||||
|
Wake-Controls $x
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function Adb {
|
||||||
|
param([Parameter(Mandatory)][string[]]$AdbArgs)
|
||||||
|
$old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
||||||
|
try { $out = & $adb @AdbArgs 2>$null; @{ out = ($out -join "`n"); code = $LASTEXITCODE } }
|
||||||
|
finally { $ErrorActionPreference = $old }
|
||||||
|
}
|
||||||
|
function AdbShell {
|
||||||
|
param([string[]]$Cmd)
|
||||||
|
(Adb -AdbArgs (@("shell") + $Cmd)).out
|
||||||
|
}
|
||||||
|
function Get-UiText {
|
||||||
|
$d = AdbShell @("uiautomator","dump","/sdcard/rm.xml")
|
||||||
|
$r = Adb @("pull","/sdcard/rm.xml","$env:TEMP\rm.xml")
|
||||||
|
if ($r.code -ne 0 -or -not (Test-Path "$env:TEMP\rm.xml")) {
|
||||||
|
Write-Host " [ui] dump=[$($d -replace "`n",' ')] pull_rc=$($r.code) -> empty"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
$txt = [System.IO.File]::ReadAllText("$env:TEMP\rm.xml", [System.Text.Encoding]::UTF8)
|
||||||
|
if ($txt.Length -lt 200) {
|
||||||
|
Write-Host " [ui] short xml ($($txt.Length) chars): $($txt.Substring(0,[Math]::Min(120,$txt.Length)))"
|
||||||
|
}
|
||||||
|
return $txt
|
||||||
|
}
|
||||||
|
function VisibleTexts([string]$xml) {
|
||||||
|
[regex]::Matches($xml,'text="([^"]+)"') | ForEach-Object { [System.Net.WebUtility]::HtmlDecode($_.Groups[1].Value) }
|
||||||
|
}
|
||||||
|
function FormFields([string]$xml) {
|
||||||
|
[regex]::Matches($xml,'<node[^>]*class="android\.widget\.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
}
|
||||||
|
function Center($m) {
|
||||||
|
$a=[int]$m.Groups[1].Value; $b=[int]$m.Groups[2].Value
|
||||||
|
$c=[int]$m.Groups[3].Value; $d=[int]$m.Groups[4].Value
|
||||||
|
@([int](($a+$c)/2), [int](($b+$d)/2))
|
||||||
|
}
|
||||||
|
function ButtonCenter([string]$xml, [string]$want) {
|
||||||
|
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||||
|
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||||
|
if ($t -eq $want) {
|
||||||
|
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if ($b.Success) {
|
||||||
|
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
|
||||||
|
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
|
||||||
|
return @([int](($x1+$x2)/2), [int](($y1+$y2)/2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
function ButtonStartingWith([string]$xml, [string]$prefix) {
|
||||||
|
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||||
|
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||||
|
if ($t.StartsWith($prefix)) {
|
||||||
|
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if ($b.Success) {
|
||||||
|
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
|
||||||
|
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
|
||||||
|
return @([int](($x1+$x2)/2), [int](($y1+$y2)/2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4a self-adaptation: in landscape the control bar auto-hides after 3s idle
|
||||||
|
# (and the HUD TextView hides with it). The wake tap goes to the LEFT BLACK BAR
|
||||||
|
# (x=100, outside the letterboxed video) - the app consumes black-bar touches
|
||||||
|
# only in the hidden state, so this never reaches the remote. Bottom-center is
|
||||||
|
# NOT usable: with the controls hidden the video is full-height, so the bottom
|
||||||
|
# edge lies INSIDE the picture and would fire a real remote click.
|
||||||
|
function PhoneDisplay {
|
||||||
|
$d = (AdbShell @("dumpsys","window","displays"))
|
||||||
|
$idx = $d.IndexOf("Display: mDisplayId=0")
|
||||||
|
if ($idx -lt 0) { return @{ w=1080; h=2400 } }
|
||||||
|
$m = [regex]::Match($d.Substring($idx), 'cur=(\d+)x(\d+)')
|
||||||
|
if (-not $m.Success) { return @{ w=1080; h=2400 } }
|
||||||
|
return @{ w=[int]$m.Groups[1].Value; h=[int]$m.Groups[2].Value }
|
||||||
|
}
|
||||||
|
function Wake-Controls([string]$xml) {
|
||||||
|
if ($xml -match 'class="android\.widget\.Button"') { return } # visible already
|
||||||
|
$pd = PhoneDisplay
|
||||||
|
[void](AdbShell @("input","tap","100","$([int]($pd.h/2))"))
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
# HUD text with wake-retry (4a: in landscape the HUD TextView hides WITH the
|
||||||
|
# control bar - a plain read returns "" once the 3s timer expired, and each
|
||||||
|
# dump itself costs ~2s so reads routinely straddle the expiry).
|
||||||
|
function Get-HudText {
|
||||||
|
for ($t=0; $t -lt 3; $t++) {
|
||||||
|
$x = Get-UiText
|
||||||
|
$m = [regex]::Match($x, 'text="([^"]*RTT=[^"]*)"')
|
||||||
|
if ($m.Success) { return [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value) }
|
||||||
|
Wake-Controls $x
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Toggle the pointer mode and PROVE it flipped by re-reading the button label.
|
||||||
|
# The bottom bar sits at y=2256..2324, right against the system navigation bar;
|
||||||
|
# a single tap there is not reliable (measured 2026-10-31: the tap landed but the
|
||||||
|
# mode did not change). So: tap, wait, re-read, retry; and judge on the BUTTON
|
||||||
|
# label as well as the HUD (two independent observations of the same state).
|
||||||
|
function Get-UiTextRetry([int]$tries = 4) {
|
||||||
|
# landscape_proof Get-UiRetry pattern (N24-era lesson): uiautomator
|
||||||
|
# intermittently returns short/empty trees; a single miss inside a
|
||||||
|
# wake-then-read sequence used to burn the whole mode-switch budget
|
||||||
|
# (measured 2026-11-02 step 3: full run died at "mode button not found"
|
||||||
|
# although the very next steps would have re-dumped fine).
|
||||||
|
$x = ""
|
||||||
|
for ($i = 0; $i -lt $tries; $i++) {
|
||||||
|
$x = Get-UiText
|
||||||
|
if ($x.Length -gt 500) { return $x }
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
}
|
||||||
|
return $x
|
||||||
|
}
|
||||||
|
function Set-Mode([string]$want) {
|
||||||
|
# $want is "relative" or "absolute"
|
||||||
|
$wantTag = if ($want -eq "relative") { $F_RELATIVE } else { $F_ABSOLUTE }
|
||||||
|
for ($try=0; $try -lt 6; $try++) {
|
||||||
|
$xml = Get-UiTextRetry
|
||||||
|
$cur = $null
|
||||||
|
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||||
|
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||||
|
if ($t.Contains($F_MODE_TAG)) {
|
||||||
|
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||||
|
if ($b.Success) {
|
||||||
|
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
|
||||||
|
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
|
||||||
|
$cur = @{ label=$t; cx=[int](($x1+$x2)/2); cy=[int](($y1+$y2)/2) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $cur) {
|
||||||
|
# 4a: controls may be auto-hidden (landscape). Wake and fold the miss
|
||||||
|
# into the SAME try budget instead of a single re-read + instant throw.
|
||||||
|
Wake-Controls $xml
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Write-Host " mode button label now: [$($cur.label)] (try $try)"
|
||||||
|
if ($cur.label.Contains($wantTag)) { return $true }
|
||||||
|
[void](AdbShell @("input","tap","$($cur.cx)","$($cur.cy)"))
|
||||||
|
Start-Sleep -Milliseconds 700
|
||||||
|
}
|
||||||
|
# Fail LOUD with evidence: a bare text dump cannot show what the screen
|
||||||
|
# actually looked like (this is how the 2026-11-02 step-3 death became a
|
||||||
|
# guessing game; one PNG makes the next occurrence self-evidencing).
|
||||||
|
$evShot = Join-Path $ev "mode-switch-fail-$PID.png"
|
||||||
|
try { & powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out $evShot | Out-Null } catch { }
|
||||||
|
$final = ((VisibleTexts (Get-UiTextRetry)) -join " || ")
|
||||||
|
throw "mode did not switch to $want after 6 tries; screen: [$final] shot: $evShot"
|
||||||
|
}
|
||||||
|
# The video SurfaceView bounds - touch coordinates must land inside it.
|
||||||
|
#
|
||||||
|
# uiautomator does NOT expose SurfaceView as "android.view.SurfaceView"; a bare
|
||||||
|
# SurfaceView with no accessibility node collapses to a plain "android.view.View"
|
||||||
|
# (measured 2026-10-31 on the live device: the only candidate is 1080x607, which
|
||||||
|
# is exactly the 16:9 letterbox of the 1920x1080 desktop inside a 1080-wide stage).
|
||||||
|
# So identify it structurally: the widest View whose aspect ratio matches 16:9.
|
||||||
|
# Assert the ratio instead of assuming it - a silent wrong node would make every
|
||||||
|
# downstream coordinate assertion meaningless.
|
||||||
|
function SurfaceBounds([string]$xml) {
|
||||||
|
$best = $null; $bestErr = [double]::MaxValue
|
||||||
|
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.view\.View"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')) {
|
||||||
|
$x1=[int]$n.Groups[1].Value; $y1=[int]$n.Groups[2].Value
|
||||||
|
$x2=[int]$n.Groups[3].Value; $y2=[int]$n.Groups[4].Value
|
||||||
|
$w = $x2-$x1; $h = $y2-$y1
|
||||||
|
if ($w -lt 300 -or $h -lt 100) { continue }
|
||||||
|
$ratio = $w / [double]$h
|
||||||
|
$err = [Math]::Abs($ratio - (16.0/9.0))
|
||||||
|
if ($err -lt $bestErr) { $bestErr = $err; $best = @($x1,$y1,$x2,$y2) }
|
||||||
|
}
|
||||||
|
if (-not $best) { return $null }
|
||||||
|
# sanity: the chosen node must really be ~16:9, else we grabbed something else
|
||||||
|
$w = $best[2]-$best[0]; $h = $best[3]-$best[1]
|
||||||
|
if ($bestErr -gt 0.25) {
|
||||||
|
throw "no 16:9 view found (best candidate ${w}x${h}, ratio $([Math]::Round($w/[double]$h,3)))"
|
||||||
|
}
|
||||||
|
Write-Host " video view ${w}x${h} (ratio $([Math]::Round($w/[double]$h,3)), 16:9 error $([Math]::Round($bestErr,4)))"
|
||||||
|
return $best
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeAscii([string]$s) {
|
||||||
|
foreach ($ch in $s.ToUpper().ToCharArray()) {
|
||||||
|
$k = if ($ch -ge '0' -and $ch -le '9') { 7 + [int][string]$ch }
|
||||||
|
elseif ($ch -ge 'A' -and $ch -le 'Z') { 29 + ([int]$ch - [int][char]'A') }
|
||||||
|
elseif ($ch -eq '.') { 56 }
|
||||||
|
else { throw "unsupported char: $ch" }
|
||||||
|
[void](AdbShell @("input","keyevent","$k"))
|
||||||
|
Start-Sleep -Milliseconds 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function FocusedIndex([string]$xml) {
|
||||||
|
$i = -1
|
||||||
|
foreach ($n in [regex]::Matches($xml,'<node[^>]*>')) {
|
||||||
|
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
||||||
|
$i++
|
||||||
|
if ($n.Value -match 'focused="true"') { return $i }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
function Focus-Field($c, [int]$wantIdx) {
|
||||||
|
for ($try=0; $try -lt 3; $try++) {
|
||||||
|
[void](AdbShell @("input","tap","$($c[0])","$($c[1])"))
|
||||||
|
$deadline = (Get-Date).AddSeconds(1.5)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
if ((FocusedIndex (Get-UiText)) -eq $wantIdx) { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw "tap did not focus field $wantIdx after 3 tries"
|
||||||
|
}
|
||||||
|
function Clear-Field($c, [int]$idx) {
|
||||||
|
Focus-Field $c $idx
|
||||||
|
for ($i=0; $i -lt 40; $i++) { [void](AdbShell @("input","keyevent","67")) }
|
||||||
|
Start-Sleep -Milliseconds 150
|
||||||
|
}
|
||||||
|
function Restart-AppForm {
|
||||||
|
[void](AdbShell @("input","keyevent","224"))
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
[void](AdbShell @("wm","dismiss-keyguard"))
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
[void](AdbShell @("am","force-stop","com.verify.remote"))
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
[void](AdbShell @("am","start","-n","com.verify.remote/.MainActivity"))
|
||||||
|
$deadline = (Get-Date).AddSeconds(8)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$x = Get-UiText
|
||||||
|
if ((FormFields $x).Count -ge 3) { return $x }
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
}
|
||||||
|
$diag = (VisibleTexts (Get-UiText)) -join " || "
|
||||||
|
throw "connect form did not appear. screen: [$diag]"
|
||||||
|
}
|
||||||
|
function FillAndConnect([string]$xml, [string]$host_, [string]$port_, [string]$code_) {
|
||||||
|
$f = FormFields $xml
|
||||||
|
if ($f.Count -lt 3) { throw "form has $($f.Count) fields" }
|
||||||
|
Clear-Field (Center $f[0]) 0; TypeAscii $host_
|
||||||
|
Clear-Field (Center $f[1]) 1; TypeAscii $port_
|
||||||
|
Clear-Field (Center $f[2]) 2; TypeAscii $code_
|
||||||
|
$expect = @($host_, $port_, $code_)
|
||||||
|
for ($retry=0; $retry -le 2; $retry++) {
|
||||||
|
$x2 = Get-UiText
|
||||||
|
$vals = @()
|
||||||
|
foreach ($n in [regex]::Matches($x2,'<node[^>]*>')) {
|
||||||
|
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
||||||
|
$vals += [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host " form now: $($vals -join ' | ') (try $retry)"
|
||||||
|
if ($vals.Count -lt 3) { throw "expected 3 fields after typing, got $($vals.Count)" }
|
||||||
|
$bad = -1
|
||||||
|
for ($i=0; $i -lt 3; $i++) { if ($vals[$i] -ne $expect[$i]) { $bad = $i; break } }
|
||||||
|
if ($bad -ge 0) {
|
||||||
|
$f = FormFields $x2
|
||||||
|
Clear-Field (Center $f[$bad]) $bad
|
||||||
|
TypeAscii $expect[$bad]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ($vals[0] -ne $host_) { throw "form input FAIL: host is [$($vals[0])], expected [$host_]" }
|
||||||
|
if ($vals[1] -ne $port_) { throw "form input FAIL: port is [$($vals[1])], expected [$port_]" }
|
||||||
|
if ($vals[2] -ne $code_) { throw "form input FAIL: code is [$($vals[2])], expected [$code_]" }
|
||||||
|
$btn = $null
|
||||||
|
foreach ($want in @($F_CONNECT, $F_RECONNECT)) { $btn = ButtonCenter $x2 $want; if ($btn) { break } }
|
||||||
|
if (-not $btn) { throw "no primary connect/reconnect button found" }
|
||||||
|
[void](AdbShell @("input","tap","$($btn[0])","$($btn[1])"))
|
||||||
|
}
|
||||||
|
function Wait-Live([int]$seconds, [ref]$hud) {
|
||||||
|
$deadline = (Get-Date).AddSeconds($seconds)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$m = [regex]::Match((Get-UiText),'text="([^"]*RTT=[^"]*)"')
|
||||||
|
if ($m.Success) { $hud.Value = [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value); return $true }
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- PC-side cursor readback (the only honest judge of "pointer moved N px") ----
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public class PC {
|
||||||
|
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
|
||||||
|
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr ctx);
|
||||||
|
[DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);
|
||||||
|
public static void Dpi() { SetProcessDpiAwarenessContext(new IntPtr(-4)); }
|
||||||
|
public static int[] Get() { POINT p; GetCursorPos(out p); return new int[]{p.X,p.Y}; }
|
||||||
|
public static void Set(int x,int y) { SetCursorPos(x,y); }
|
||||||
|
public static int W() { return GetSystemMetrics(0); }
|
||||||
|
public static int H() { return GetSystemMetrics(1); }
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
[void][PC]::Dpi()
|
||||||
|
$SW = [PC]::W(); $SH = [PC]::H()
|
||||||
|
Write-Host " PC screen ${SW}x${SH} (physical, DPI-aware)"
|
||||||
|
|
||||||
|
Write-Host "=== 0. stale-artifact guard (R-7) + IME disable + keep-awake ==="
|
||||||
|
if (-not $AgentExe) { $AgentExe = Join-Path $repo "target\release\agent.exe" }
|
||||||
|
$ag = @(Get-Process agent -ErrorAction SilentlyContinue)
|
||||||
|
if ($ag.Count -gt 0 -and (Test-Path $AgentExe) -and ((Get-Item $AgentExe).LastWriteTime -gt $ag[0].StartTime)) {
|
||||||
|
Write-Host " running agent predates current build; restarting"
|
||||||
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 800; $ag = @()
|
||||||
|
}
|
||||||
|
if ($ag.Count -eq 0) {
|
||||||
|
if (-not (Test-Path $AgentExe)) { throw "missing $AgentExe" }
|
||||||
|
Start-Process $AgentExe -ArgumentList "--port","$Port","--code",$Code -WindowStyle Hidden | Out-Null
|
||||||
|
Start-Sleep -Seconds 1; Write-Host " agent started (v5)"
|
||||||
|
} else { Write-Host " agent running and current" }
|
||||||
|
|
||||||
|
$origIme = ((AdbShell @("settings","get","secure","default_input_method")) -join "").Trim()
|
||||||
|
Write-Host " original IME: [$origIme] (recorded, restored in finally)"
|
||||||
|
if ($origIme -and $origIme -ne "null") { [void](AdbShell @("ime","disable",$origIme)) }
|
||||||
|
[void](AdbShell @("svc","power","stayon","true"))
|
||||||
|
|
||||||
|
$origCursor = [PC]::Get()
|
||||||
|
Write-Host " PC cursor origin ($($origCursor[0]),$($origCursor[1])) (restored in finally)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# =================================================================
|
||||||
|
Write-Host "=== 1. connect (protocol v4) and confirm HUD shows v4 ==="
|
||||||
|
$x = Restart-AppForm
|
||||||
|
[void](FillAndConnect $x $Host_ "$Port" $Code)
|
||||||
|
$hud = ""
|
||||||
|
if (-not (Wait-Live 14 ([ref]$hud))) { throw "never reached a live session" }
|
||||||
|
Write-Host " HUD: $hud"
|
||||||
|
# HUD version pin (3b/V-B4). PROTO bump MUST sync this string -
|
||||||
|
# see MainActivity PROTO_LABEL comment (4b R-5 pair).
|
||||||
|
if (-not $hud.Contains("v5")) { throw "V-B4 FAIL: HUD does not report protocol v5; got [$hud]" }
|
||||||
|
Write-Host " PASS: live session on protocol v5"
|
||||||
|
|
||||||
|
$xml = Get-UiText
|
||||||
|
$sb = SurfaceBounds $xml
|
||||||
|
if (-not $sb) { throw "cannot locate SurfaceView bounds" }
|
||||||
|
$vx1=$sb[0]; $vy1=$sb[1]; $vx2=$sb[2]; $vy2=$sb[3]
|
||||||
|
$vcx=[int](($vx1+$vx2)/2); $vcy=[int](($vy1+$vy2)/2)
|
||||||
|
Write-Host " surface bounds = [$vx1,$vy1][$vx2,$vy2] center=($vcx,$vcy)"
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-b1-live.png" | Out-Null
|
||||||
|
|
||||||
|
# 4a pollution guard: the mode PERSISTS in prefs across runs, and a run that
|
||||||
|
# threw between Set-Mode "absolute" and its restore left absolute installed
|
||||||
|
# (measured 2026-11-01: step 2 ran in absolute mode, the DOWN absolute move
|
||||||
|
# "teleported" 1441px - correct product behavior, wrong precondition).
|
||||||
|
# Force relative, then settle back into the hidden steady state: while the
|
||||||
|
# controls are hidden, gestures never re-show them, so the geometry stays
|
||||||
|
# stable for steps 2/2b/2c.
|
||||||
|
$hudNow = Get-HudText
|
||||||
|
Write-Host " HUD at start: $hudNow"
|
||||||
|
# Converge to relative UNCONDITIONALLY via Set-Mode, which reads the BUTTON
|
||||||
|
# label and returns early if already relative (idempotent, authoritative).
|
||||||
|
# The old guard `if ($hudNow.Contains(absolute))` silently skipped when the
|
||||||
|
# HUD read came back empty or straddled the auto-hide (N24 observation-surface
|
||||||
|
# trap) - a mode left polluted by a previous run that threw after Set-Mode
|
||||||
|
# absolute then poisoned step 2c: absolute mode's DOWN DOES send an absolute
|
||||||
|
# move (correct product behavior), which the leak-point check read as a
|
||||||
|
# teleport (measured again 2026-11-02: prefs relative_mode=false at start,
|
||||||
|
# V-B1a "passed" on direction alone because absolute swipes also have
|
||||||
|
# direction - the whole step 2 block was running in the wrong mode).
|
||||||
|
[void](Set-Mode "relative")
|
||||||
|
Start-Sleep -Milliseconds 3800 # let the 3s auto-hide re-engage
|
||||||
|
$xh = Get-UiText
|
||||||
|
if ($xh -match 'class="android\.widget\.Button"') { throw "precondition FAIL: controls did not re-hide after 3.8s" }
|
||||||
|
$hsb = SurfaceBounds $xh
|
||||||
|
# NB: PowerShell variable names are CASE-INSENSITIVE - a local `$sh` here
|
||||||
|
# silently clobbers `$SH` (screen height) and every later [PC]::Set($SW/2,$SH/2)
|
||||||
|
# dies with "Object[] has no op_Division" (measured 2026-11-01). Prefix locals
|
||||||
|
# distinctly ($hsb = hidden-state bounds).
|
||||||
|
if (-not $hsb) { throw "precondition FAIL: no video bounds in hidden state" }
|
||||||
|
$vx1=$hsb[0]; $vy1=$hsb[1]; $vx2=$hsb[2]; $vy2=$hsb[3]
|
||||||
|
$vcx=[int](($vx1+$vx2)/2); $vcy=[int](($vy1+$vy2)/2)
|
||||||
|
Write-Host " hidden steady-state bounds = [$vx1,$vy1][$vx2,$vy2] center=($vcx,$vcy)"
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
Write-Host "=== 2. V-B1a: relative mode moves PC pointer (swipe right) ==="
|
||||||
|
# Park the PC cursor at a known point so the delta is unambiguous.
|
||||||
|
# Let the link drain first (same discipline as 2c): residual events from an
|
||||||
|
# earlier gesture would otherwise be attributed to this one.
|
||||||
|
Start-Sleep -Milliseconds 1000
|
||||||
|
[PC]::Set([int]($SW/2), [int]($SH/2))
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$before = [PC]::Get()
|
||||||
|
# A horizontal swipe of ~40% of the view width. With sensitivity 1.0 and
|
||||||
|
# view width mapping to desktop width, expected delta = 0.4 * SW.
|
||||||
|
$swipeFromX = [int]($vx1 + ($vx2-$vx1)*0.30)
|
||||||
|
$swipeToX = [int]($vx1 + ($vx2-$vx1)*0.70)
|
||||||
|
[void](AdbShell @("input","swipe","$swipeFromX","$vcy","$swipeToX","$vcy","400"))
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$after = [PC]::Get()
|
||||||
|
$moved = $after[0] - $before[0]
|
||||||
|
Write-Host " swipe dx_device=$($swipeToX-$swipeFromX)px -> PC pointer dx=$moved px (before x=$($before[0]), after x=$($after[0]))"
|
||||||
|
if ($moved -le 0) { throw "V-B1a FAIL: rightward swipe did not move the pointer right (dx=$moved)" }
|
||||||
|
# Direction check is the hard judgement; exact magnitude depends on how many
|
||||||
|
# ACTION_MOVE samples the ROM delivers for an `input swipe` (not equal to the
|
||||||
|
# finger travel), so magnitude is reported, not asserted.
|
||||||
|
Write-Host " PASS: relative mode moves the pointer in the finger's direction"
|
||||||
|
|
||||||
|
Write-Host "=== 2b. V-B1a negative: swipe left moves pointer left ==="
|
||||||
|
Start-Sleep -Milliseconds 1000
|
||||||
|
[PC]::Set([int]($SW/2), [int]($SH/2))
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$before2 = [PC]::Get()
|
||||||
|
[void](AdbShell @("input","swipe","$swipeToX","$vcy","$swipeFromX","$vcy","400"))
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$after2 = [PC]::Get()
|
||||||
|
$moved2 = $after2[0] - $before2[0]
|
||||||
|
Write-Host " PC pointer dx=$moved2 px (before x=$($before2[0]), after x=$($after2[0]))"
|
||||||
|
if ($moved2 -ge 0) { throw "V-B1a FAIL: leftward swipe did not move the pointer left (dx=$moved2)" }
|
||||||
|
Write-Host " PASS: negative direction correct (sign preserved)"
|
||||||
|
|
||||||
|
Write-Host "=== 2c. relative mode does NOT teleport the pointer on touch-down ==="
|
||||||
|
# The defining property of relative mode: ACTION_DOWN must not send an
|
||||||
|
# absolute move. Tap at the far LEFT of the view; the pointer must not jump
|
||||||
|
# to the left edge of the desktop.
|
||||||
|
#
|
||||||
|
# DISCRIMINATION (2026-11-01, landscape): a real absolute-leak has an exact
|
||||||
|
# signature - the pointer lands at the tap's desktop mapping
|
||||||
|
# (0.05*1920=96, 540) and STAYS. Residual swipe events instead keep the
|
||||||
|
# cursor DRIFTING for seconds (observed: stable-looking 4-read windows
|
||||||
|
# followed by -890/-1418px surges - bursts with >1.2s gaps between them).
|
||||||
|
# So: settle (6 equal reads), tap, then sample 4x over 2s.
|
||||||
|
# all samples == before -> PASS
|
||||||
|
# samples drift -> harness residue: retry (max 3)
|
||||||
|
# settled AT leak point -> product FAIL (no retry will change it)
|
||||||
|
# Absolute-leak landing point for this tap: view fraction 0.05 maps to
|
||||||
|
# desktop x = 0.05*(SW-1); y = view center row -> desktop mid row.
|
||||||
|
$leakX = [int][Math]::Round(0.05 * ($SW-1))
|
||||||
|
$leakY = [int][Math]::Round((($vcy-$vy1) / [double]($vy2-$vy1)) * ($SH-1))
|
||||||
|
$verdict = "retry"
|
||||||
|
for ($att=1; $att -le 3 -and $verdict -eq "retry"; $att++) {
|
||||||
|
Start-Sleep -Milliseconds 2000
|
||||||
|
[PC]::Set([int]($SW*0.8), [int]($SH/2))
|
||||||
|
Start-Sleep -Milliseconds 1500
|
||||||
|
$stable = $false
|
||||||
|
for ($i=0; $i -lt 8; $i++) {
|
||||||
|
$runs = @()
|
||||||
|
for ($k=0; $k -lt 6; $k++) { $runs += ,([PC]::Get()); Start-Sleep -Milliseconds 250 }
|
||||||
|
$eq = $true
|
||||||
|
foreach ($r in $runs) { if ($r[0] -ne $runs[0][0] -or $r[1] -ne $runs[0][1]) { $eq = $false } }
|
||||||
|
if ($eq) { $stable = $true; break }
|
||||||
|
Write-Host (" att{0}: cursor still settling (last {1},{2}); re-parking" -f $att,$runs[-1][0],$runs[-1][1])
|
||||||
|
[PC]::Set([int]($SW*0.8), [int]($SH/2))
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
}
|
||||||
|
if (-not $stable) { throw "cannot obtain a stable cursor baseline (residual events never drained)" }
|
||||||
|
$beforeTap = [PC]::Get()
|
||||||
|
$tapX = [int]($vx1 + ($vx2-$vx1)*0.05)
|
||||||
|
[void](AdbShell @("input","tap","$tapX","$vcy"))
|
||||||
|
$samples = @()
|
||||||
|
for ($k=0; $k -lt 4; $k++) { Start-Sleep -Milliseconds 500; $samples += ,([PC]::Get()) }
|
||||||
|
$dbgTap = ((VisibleTexts (Get-UiText)) -join " || ")
|
||||||
|
$maxJump = 0
|
||||||
|
foreach ($s in $samples) {
|
||||||
|
$j = [Math]::Max([Math]::Abs($s[0]-$beforeTap[0]), [Math]::Abs($s[1]-$beforeTap[1]))
|
||||||
|
if ($j -gt $maxJump) { $maxJump = $j }
|
||||||
|
}
|
||||||
|
$last = $samples[-1]
|
||||||
|
$drift = $false
|
||||||
|
for ($k=1; $k -lt 4; $k++) {
|
||||||
|
if ($samples[$k][0] -ne $samples[0][0] -or $samples[$k][1] -ne $samples[0][1]) { $drift = $true }
|
||||||
|
}
|
||||||
|
$atLeak = ([Math]::Abs($last[0]-$leakX) -le 8) -and ([Math]::Abs($last[1]-$leakY) -le 8)
|
||||||
|
# NB: "$_[0]" inside a double-quoted string expands only `$_` and prints
|
||||||
|
# "[0]" literally (PS 5.1) - use explicit sub-expressions.
|
||||||
|
$sampleStr = ($samples | ForEach-Object { "(" + $_[0] + "," + $_[1] + ")" }) -join " "
|
||||||
|
Write-Host (" att{0}: tap ({1},{2}) -> samples {3} maxJump={4} drift={5} atLeakPoint={6} (leak=({7},{8}))" -f `
|
||||||
|
$att,$beforeTap[0],$beforeTap[1],$sampleStr,$maxJump,$drift,$atLeak,$leakX,$leakY)
|
||||||
|
if ($dbgTap.Contains($F_LONGPRESS)) { throw "tap was interpreted as a long press; harness timing is wrong" }
|
||||||
|
if ($atLeak) { throw ("V-B1a FAIL: pointer settled at the absolute-leak point ({0},{1}) - DOWN sent an absolute move" -f $last[0],$last[1]) }
|
||||||
|
if ($maxJump -le 8 -and -not $drift) { $verdict = "pass"; break }
|
||||||
|
if (-not $drift) {
|
||||||
|
# settled far away but NOT at the leak point: some other single event
|
||||||
|
# moved it - that IS a product-relevant teleport, retry once more
|
||||||
|
# before condemning (a queued burst can also settle far away).
|
||||||
|
Write-Host " att${att}: settled far (jump=$maxJump) but not at leak point - treating as residue, retrying"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($verdict -ne "pass") { throw "V-B1a FAIL: pointer moved on touch-down in relative mode across 3 attempts (residual or teleport - see samples above)" }
|
||||||
|
Write-Host " PASS: relative mode leaves the pointer where it was (no teleport)"
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
Write-Host "=== 3. V-B2: switch to ABSOLUTE mode, re-run R7 touch mapping ==="
|
||||||
|
[void](Set-Mode "absolute")
|
||||||
|
# 4a geometry race (measured 2026-11-01): Set-Mode WOK the controls, so the
|
||||||
|
# video shrank to 1216x684 - but each dump costs ~2s and the auto-hide timer
|
||||||
|
# is 3s, so by tap time the controls had hidden again and the video was back
|
||||||
|
# to full height. Tapping with the stale visible-state geometry produced
|
||||||
|
# "errors" of 281-357px that were pure harness error (y landed at the old
|
||||||
|
# center row 341, not 540). Wait for the hidden steady state and sample
|
||||||
|
# THOSE bounds - in hidden state a video tap only re-arms the timer, it never
|
||||||
|
# re-shows the controls, so the geometry is stable for the whole R7 loop.
|
||||||
|
Start-Sleep -Milliseconds 3800
|
||||||
|
$hud2 = Get-HudText
|
||||||
|
$sb = SurfaceBounds (Get-UiText)
|
||||||
|
if (-not $sb) { throw "V-B2 FAIL: no video bounds in hidden steady state" }
|
||||||
|
$vx1=$sb[0];$vy1=$sb[1];$vx2=$sb[2];$vy2=$sb[3]
|
||||||
|
$vcx=[int](($vx1+$vx2)/2); $vcy=[int](($vy1+$vy2)/2)
|
||||||
|
Write-Host " hidden steady-state bounds = [$vx1,$vy1][$vx2,$vy2] center=($vcx,$vcy)"
|
||||||
|
Write-Host " HUD: $hud2"
|
||||||
|
if (-not $hud2.Contains($F_ABSOLUTE)) { throw "V-B2 FAIL: HUD still does not say absolute; got [$hud2]" }
|
||||||
|
Write-Host " PASS: mode switched to absolute (button label and HUD agree)"
|
||||||
|
|
||||||
|
# R7 expectations derived from the LIVE desktop size (physical pixels; 125%
|
||||||
|
# scaling does NOT apply - the agent injects into the physical desktop, and
|
||||||
|
# normX=frac*65535 lands at frac*(SW-1)).
|
||||||
|
# Fixed 1920-based literals broke the moment the capture display became
|
||||||
|
# 2560x1440 (2026-11-02: reported 575px error = 0.9*(2560-1920), a harness
|
||||||
|
# staleness, not a mapping regression). AGENTS.md: coordinate expectations
|
||||||
|
# MUST be recomputed for the current resolution. Same script already uses
|
||||||
|
# dynamic $SW/$SH for V-B1a and the leak point - this block was the outlier.
|
||||||
|
$r7 = @(
|
||||||
|
@{ frac=0.10 },
|
||||||
|
@{ frac=0.90 },
|
||||||
|
@{ frac=0.50 }
|
||||||
|
)
|
||||||
|
$worst = 0
|
||||||
|
foreach ($c in $r7) {
|
||||||
|
$tx = [int]($vx1 + ($vx2-$vx1)*$c.frac)
|
||||||
|
$ty = [int]($vy1 + ($vy2-$vy1)*$c.frac)
|
||||||
|
$c.ex = [int][Math]::Round($c.frac * ($SW-1))
|
||||||
|
$c.ey = [int][Math]::Round($c.frac * ($SH-1))
|
||||||
|
[void](AdbShell @("input","tap","$tx","$ty"))
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
$p = [PC]::Get()
|
||||||
|
$errX = [Math]::Abs($p[0] - $c.ex)
|
||||||
|
$errY = [Math]::Abs($p[1] - $c.ey)
|
||||||
|
$e = [Math]::Max($errX,$errY)
|
||||||
|
if ($e -gt $worst) { $worst = $e }
|
||||||
|
Write-Host " frac=$($c.frac) expect=($($c.ex),$($c.ey)) actual=($($p[0]),$($p[1])) err=($errX,$errY)"
|
||||||
|
}
|
||||||
|
Write-Host " worst R7 error = $worst px"
|
||||||
|
if ($worst -gt 8) { throw "V-B2 FAIL: R7 absolute mapping error $worst px > 8px" }
|
||||||
|
Write-Host " PASS: absolute mode still maps 1:1 (R7 regression green)"
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
Write-Host "=== 4. V-B3: long press opens a context menu; drag does NOT ==="
|
||||||
|
# Switch back to relative for the long-press test (that is the default mode
|
||||||
|
# users get, and the gesture must work there).
|
||||||
|
[void](Set-Mode "relative")
|
||||||
|
|
||||||
|
# Use a desktop of our own: the proof's WinForms target window (R-11: never
|
||||||
|
# inject into a window we do not own - it would modify user data).
|
||||||
|
# NB: the 3b version launched this WITHOUT its Mandatory params, so the
|
||||||
|
# child died instantly and the right-click landed at raw screen center
|
||||||
|
# (possible user window - near-miss on R-11). Launch properly now and park
|
||||||
|
# the pointer INSIDE our own window: topmost form at (80,80) 420x140.
|
||||||
|
$target = Join-Path $repo "tools\text_target.ps1"
|
||||||
|
if (-not (Test-Path $target)) { throw "missing $target" }
|
||||||
|
# UNIQUE per-run dir (same orphan-stop-flag trap as phone_chain_proof,
|
||||||
|
# fixed 2026-11-01): startup Remove-Item must not delete the retirement
|
||||||
|
# channel of targets left by a previous failed run.
|
||||||
|
$ttmp = Join-Path $env:TEMP "vbx4a-$PID"
|
||||||
|
New-Item -ItemType Directory -Force -Path $ttmp | Out-Null
|
||||||
|
$pidFile = "$ttmp\target.pid"; $stopFile = "$ttmp\stop.flag"; $resultFile = "$ttmp\received.txt"
|
||||||
|
Remove-Item $pidFile,$stopFile,$resultFile -ErrorAction SilentlyContinue
|
||||||
|
$tp = Start-Process powershell -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass","-File",$target, `
|
||||||
|
"-PidFile",$pidFile,"-ResultFile",$resultFile,"-StopFile",$stopFile -PassThru -WindowStyle Hidden
|
||||||
|
$deadline = (Get-Date).AddSeconds(8)
|
||||||
|
while (-not (Test-Path $pidFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 200 }
|
||||||
|
if (-not (Test-Path $pidFile)) { throw "target window did not start (no pid file)" }
|
||||||
|
Write-Host " target window started (pid $(Get-Content $pidFile))"
|
||||||
|
|
||||||
|
# Long-press and drag run in the HIDDEN steady state: geometry is stable
|
||||||
|
# there (video taps only re-arm the hide timer, they never re-show the
|
||||||
|
# controls), and the counter read wakes the HUD afterwards.
|
||||||
|
function Enter-HiddenSteady {
|
||||||
|
Start-Sleep -Milliseconds 3800
|
||||||
|
$x = Get-UiText
|
||||||
|
if ($x -match 'class="android\.widget\.Button"') { throw "precondition FAIL: controls did not hide" }
|
||||||
|
$s = SurfaceBounds $x
|
||||||
|
if (-not $s) { throw "precondition FAIL: no hidden-state video bounds" }
|
||||||
|
return $s
|
||||||
|
}
|
||||||
|
|
||||||
|
$rcBefore = RightClickCount # may wake the controls; re-enter hidden after
|
||||||
|
$hsb4 = Enter-HiddenSteady
|
||||||
|
$hx1=$hsb4[0];$hy1=$hsb4[1];$hx2=$hsb4[2];$hy2=$hsb4[3]
|
||||||
|
$hcx=[int](($hx1+$hx2)/2); $hcy=[int](($hy1+$hy2)/2)
|
||||||
|
Write-Host " right-click counter before: $rcBefore ; hidden center=($hcx,$hcy)"
|
||||||
|
# The long press right-clicks AT THE REMOTE POINTER, so park the pointer on
|
||||||
|
# our own target window first - the menu then opens over a window we own.
|
||||||
|
[PC]::Set(290, 150)
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
[void](AdbShell @("input","swipe","$hcx","$hcy","$hcx","$hcy","900")) # press & hold ~900ms, no movement
|
||||||
|
Start-Sleep -Milliseconds 700
|
||||||
|
$rcAfter = RightClickCount
|
||||||
|
$debug = ((VisibleTexts (Get-UiText)) -join " || ")
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-b3-longpress.png" | Out-Null
|
||||||
|
Write-Host " right-click counter after : $rcAfter (source tag in HUD)"
|
||||||
|
Write-Host " screen after long press: $debug"
|
||||||
|
if ($rcBefore -lt 0 -or $rcAfter -lt 0) { throw "V-B3 FAIL: could not read the right-click counter from the HUD" }
|
||||||
|
if ($rcAfter -le $rcBefore) {
|
||||||
|
throw "V-B3 FAIL: long press did not inject a right click (counter stayed $rcAfter; screen: $debug)"
|
||||||
|
}
|
||||||
|
Write-Host " PASS: long press injected a right click (counter $rcBefore -> $rcAfter)"
|
||||||
|
|
||||||
|
Write-Host "=== 4b. V-B3 negative: a DRAG must not fire long press ==="
|
||||||
|
$rcBeforeDrag = RightClickCount
|
||||||
|
Enter-HiddenSteady | Out-Null
|
||||||
|
[void](AdbShell @("input","swipe","$hcx","$hcy",$([int]($hcx+120)),$([int]($hcy+80)),900))
|
||||||
|
Start-Sleep -Milliseconds 700
|
||||||
|
$rcAfterDrag = RightClickCount
|
||||||
|
$debug2 = ((VisibleTexts (Get-UiText)) -join " || ")
|
||||||
|
Write-Host " right-click counter after drag: $rcAfterDrag (was $rcBeforeDrag)"
|
||||||
|
Write-Host " screen after drag: $debug2"
|
||||||
|
if ($rcAfterDrag -gt $rcBeforeDrag) {
|
||||||
|
throw "V-B3 FAIL: drag injected a right click (counter $rcBeforeDrag -> $rcAfterDrag); long press must be suppressed once the finger moves"
|
||||||
|
}
|
||||||
|
Write-Host " PASS: drag did not fire long press"
|
||||||
|
|
||||||
|
Write-Host "=== 4c. V-B3: two-finger right click still works ==="
|
||||||
|
$rcBeforeTwo = RightClickCount
|
||||||
|
# adb cannot synthesize a two-finger tap (lessons R-6): this is a MANUAL item.
|
||||||
|
Write-Host " (two-finger tap is not drivable via adb - recorded as manual, not asserted here)"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# 4a pollution discipline: end with no live session - the agent's 10s idle
|
||||||
|
# reclaim then happens BETWEEN runs, not in the middle of the next run's
|
||||||
|
# connect (measured 2026-11-01: a stranded zombie left the next proof unable
|
||||||
|
# to reach a live session at all).
|
||||||
|
[void](AdbShell @("am","force-stop","com.verify.remote"))
|
||||||
|
# target window exits via its stop flag (text_target contract); kill is the
|
||||||
|
# fallback. In finally so a throw never leaves a topmost window on the user's desktop.
|
||||||
|
if ($stopFile) { [void](New-Item -ItemType File -Force -Path $stopFile -ErrorAction SilentlyContinue) }
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
if ($tp -and -not $tp.HasExited) { Stop-Process -Id $tp.Id -Force -ErrorAction SilentlyContinue }
|
||||||
|
[void][PC]::Set($origCursor[0], $origCursor[1])
|
||||||
|
[void](AdbShell @("svc","power","stayon","false"))
|
||||||
|
if ($origIme -and $origIme -ne "null") {
|
||||||
|
[void](AdbShell @("ime","enable",$origIme))
|
||||||
|
[void](AdbShell @("ime","set",$origIme))
|
||||||
|
Write-Host " IME restored: $(((AdbShell @('settings','get','secure','default_input_method')) -join '').Trim())"
|
||||||
|
}
|
||||||
|
Write-Host " PC cursor restored to ($($origCursor[0]),$($origCursor[1]))"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== REL MOUSE PHONE PROOF COMPLETE ==="
|
||||||
90
tools/rendezvous.py
Normal file
90
tools/rendezvous.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""极简 rendezvous(信令)服务器 —— POC 用
|
||||||
|
|
||||||
|
作用只有一个:**告诉双方「对方看到的你」是什么地址**。
|
||||||
|
双方注册到同一个房间后,服务器把各自观察到的公网地址回推给对方,
|
||||||
|
之后双方直接打洞,服务器不再参与(也不承载任何媒体流量)。
|
||||||
|
|
||||||
|
用法(跑在有公网 IP 的机器上,比如 43.140.215.244):
|
||||||
|
py rendezvous.py --port 47999
|
||||||
|
|
||||||
|
客户端:
|
||||||
|
py punch_probe.py --server <服务器IP>:47999 --room test01 --port 47800
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 房间成员多久没动静就清掉
|
||||||
|
MEMBER_TTL = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(sockaddr):
|
||||||
|
host, port = sockaddr[0], sockaddr[1]
|
||||||
|
return "[%s]:%d" % (host, port) if ":" in host else "%s:%d" % (host, port)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="极简 rendezvous 服务器")
|
||||||
|
ap.add_argument("--bind", default="0.0.0.0")
|
||||||
|
ap.add_argument("--port", type=int, default=47999)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
# 重定向到文件时 stdout 会全缓冲,日志要等进程退出才出现;强制行缓冲
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(line_buffering=True)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.bind((args.bind, args.port))
|
||||||
|
|
||||||
|
print("[rendezvous] 监听 udp://%s:%d" % (args.bind, args.port))
|
||||||
|
print("[rendezvous] 房间成员 TTL = %.0fs,Ctrl+C 退出\n" % MEMBER_TTL)
|
||||||
|
|
||||||
|
rooms = {} # room -> [(addr, last_seen)]
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data, src = sock.recvfrom(2048)
|
||||||
|
txt = data.decode("utf-8", "replace").strip()
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if not txt.startswith("REGISTER|"):
|
||||||
|
continue
|
||||||
|
room = txt.split("|", 1)[1].strip()
|
||||||
|
if not room:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 清掉过期成员 + 去掉本机的旧记录(支持重复注册)
|
||||||
|
members = [(a, t) for (a, t) in rooms.get(room, [])
|
||||||
|
if now - t < MEMBER_TTL and a[1] != src[1]]
|
||||||
|
members.append((src, now))
|
||||||
|
rooms[room] = members
|
||||||
|
|
||||||
|
print("[rendezvous] %s 注册 room=%s (现有 %d 人) 源地址=%s"
|
||||||
|
% (fmt(src), room, len(members), fmt(src)))
|
||||||
|
|
||||||
|
if len(members) >= 2:
|
||||||
|
a1, _ = members[0]
|
||||||
|
a2, _ = members[1]
|
||||||
|
sock.sendto(b"PEER|" + fmt(a2).encode(), a1)
|
||||||
|
sock.sendto(b"PEER|" + fmt(a1).encode(), a2)
|
||||||
|
print("[rendezvous] -> 配对成功:%s <==> %s"
|
||||||
|
% (fmt(a1), fmt(a2)))
|
||||||
|
else:
|
||||||
|
sock.sendto(b"WAIT", src)
|
||||||
|
print("[rendezvous] -> 等待第二个人加入 ...")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[rendezvous] 退出")
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
23
tools/shot.ps1
Normal file
23
tools/shot.ps1
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Byte-safe device screenshot helper.
|
||||||
|
# ASCII-only. PS 5.1 `>` redirection writes UTF-16, which corrupts PNG bytes
|
||||||
|
# (measured 2026-10-30: `adb exec-out screencap -p > x.png` produced a file
|
||||||
|
# starting with FF FE). Capture on-device and pull instead.
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Out,
|
||||||
|
[string]$Device = "/sdcard/shot.png"
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||||
|
$old = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
& $adb shell screencap -p $Device | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "screencap failed ($LASTEXITCODE)" }
|
||||||
|
& $adb pull $Device $Out | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "pull failed ($LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
finally { $ErrorActionPreference = $old }
|
||||||
|
$b = [System.IO.File]::ReadAllBytes($Out)
|
||||||
|
$sig = ($b[0..3] | ForEach-Object { $_.ToString("X2") }) -join " "
|
||||||
|
if ($sig -ne "89 50 4E 47") { throw "not a PNG (sig=$sig): $Out" }
|
||||||
|
Write-Host ("OK {0} ({1:N0} bytes)" -f (Split-Path $Out -Leaf), $b.Length)
|
||||||
175
tools/text_injection_proof.ps1
Normal file
175
tools/text_injection_proof.ps1
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
# 2b V6/V7 proof: v3 text injection into a dedicated WinForms target window,
|
||||||
|
# read back from that process itself.
|
||||||
|
# ASCII-only source (PS 5.1 decodes BOM-less .ps1 as GBK; see personal lessons).
|
||||||
|
# Non-ASCII payloads are built from explicit code points, never literals.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File tools\text_injection_proof.ps1
|
||||||
|
#
|
||||||
|
# Chain: viewer-core text_check --units <utf16> -> v3 agent SendInput
|
||||||
|
# KEYEVENTF_UNICODE -> foreground WinForms TextBox -> self-reported text.
|
||||||
|
#
|
||||||
|
# Why NOT Notepad (2026-10-30): Windows 11 Notepad hosts all tabs in ONE
|
||||||
|
# process and this dev box had a user document open in it - injecting "into
|
||||||
|
# Notepad" would mean injecting into the user's real file. The WinForms target
|
||||||
|
# is a separate process we own, opened on an empty field.
|
||||||
|
#
|
||||||
|
# Foreground handling (measured, not assumed): a form with TopMost=true that
|
||||||
|
# is activated at startup KEEPS foreground for seconds; AppActivate /
|
||||||
|
# SetForegroundWindow called from here actually STEALS it away. So we assert
|
||||||
|
# the target is foreground before injecting, and refuse to inject otherwise -
|
||||||
|
# a stray injection would land in whatever window was foreground.
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
# Repo root derived from this script's location (tools/ is one level under the
|
||||||
|
# repo root) so the proof runs on any clone, not just the author's D: drive.
|
||||||
|
$repo = (Resolve-Path "$PSScriptRoot\..").Path
|
||||||
|
# UNIQUE per run: a fixed path lets the startup Remove-Item delete the stop.flag
|
||||||
|
# that leftover targets from a previous FAILED run are watching, so they never
|
||||||
|
# retire and keep writing into the shared received.txt
|
||||||
|
# (same trap fixed in phone_chain_proof, measured 2026-11-01).
|
||||||
|
$tmp = "$env:TEMP\remote-text-proof-$PID"
|
||||||
|
$base = "D:\working\environment"
|
||||||
|
|
||||||
|
$env:RUSTUP_HOME = "$base\rust"
|
||||||
|
$env:CARGO_HOME = "$base\cargo"
|
||||||
|
$env:PATH = "$base\mingw64\bin;$base\cargo\bin;" + $env:PATH
|
||||||
|
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class WinFg {
|
||||||
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||||
|
$pidFile = "$tmp\target.pid"
|
||||||
|
$stopFile = "$tmp\stop.flag"
|
||||||
|
$resultFile = "$tmp\received.txt"
|
||||||
|
Remove-Item $pidFile,$stopFile,$resultFile -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
function Wait-File {
|
||||||
|
param([string]$Path, [int]$TimeoutMs = 15000)
|
||||||
|
$tries = 0
|
||||||
|
while (-not (Test-Path $Path) -and $tries -lt ($TimeoutMs / 150)) {
|
||||||
|
Start-Sleep -Milliseconds 150; $tries++
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $Path)) { throw "file did not appear: $Path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-Received {
|
||||||
|
Wait-File $resultFile
|
||||||
|
$bytes = [System.IO.File]::ReadAllBytes($resultFile)
|
||||||
|
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||||
|
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||||
|
return $text
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodePointsToString([int[]]$cps) {
|
||||||
|
-join ($cps | ForEach-Object { [char]$_ })
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Text([string]$s) {
|
||||||
|
# ASCII-safe console preview: non-ASCII rendered as U+XXXX
|
||||||
|
($s.ToCharArray() | ForEach-Object {
|
||||||
|
if ([int]$_ -ge 32 -and [int]$_ -lt 127) { $_ } else { "U+{0:X4}" -f [int]$_ }
|
||||||
|
}) -join ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-TargetForeground([IntPtr]$hwnd) {
|
||||||
|
$fg = [WinFg]::GetForegroundWindow()
|
||||||
|
if ($fg -ne $hwnd) {
|
||||||
|
throw "foreground is $fg, not target $hwnd - refusing to inject (would hit another app)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Send-Units([int[]]$cps, [IntPtr]$hwnd, [int]$delayMs = 1500) {
|
||||||
|
$units = $cps -join ","
|
||||||
|
$exe = "$repo\target\debug\examples\text_check.exe"
|
||||||
|
if (-not (Test-Path $exe)) { throw "missing $exe (cargo build -p viewer-core --example text_check)" }
|
||||||
|
Assert-TargetForeground $hwnd
|
||||||
|
& $exe --port 47800 --code t --units $units --delay $delayMs --hold 800
|
||||||
|
$code = $LASTEXITCODE
|
||||||
|
Assert-TargetForeground $hwnd # 注入窗口在 --delay 期间必须仍是靶窗
|
||||||
|
if ($code -ne 0) { throw "text_check exited $code" }
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 0. ensure CURRENT v3 agent is the one running ==="
|
||||||
|
$ag = Get-Process agent -ErrorAction SilentlyContinue
|
||||||
|
$agentExe = "$repo\target\release\agent.exe"
|
||||||
|
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
|
||||||
|
Write-Host " running agent predates current build; restarting"
|
||||||
|
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
$ag = $null
|
||||||
|
}
|
||||||
|
if (-not $ag) {
|
||||||
|
Start-Process -FilePath $agentExe -ArgumentList "--port","47800","--code","t" -WindowStyle Hidden
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
Write-Host " agent started (v3)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== 1. launch WinForms target (own process, empty field) ==="
|
||||||
|
# NO -WindowStyle Hidden: the hidden host makes the form invisible, and an
|
||||||
|
# invisible window can never be foreground (measured 2026-10-30).
|
||||||
|
Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass",`
|
||||||
|
"-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,`
|
||||||
|
"-ResultFile",$resultFile,"-StopFile",$stopFile
|
||||||
|
Wait-File $pidFile
|
||||||
|
# target.pid holds "pid<n><hwnd>" (see text_target.ps1 Shown handler); take line 1.
|
||||||
|
$parts = @((Get-Content $pidFile -Raw) -split "`n")
|
||||||
|
$tid = [int]$parts[0].Trim()
|
||||||
|
$tp = Get-Process -Id $tid
|
||||||
|
Start-Sleep -Milliseconds 1200
|
||||||
|
$hwnd = [IntPtr][long]$parts[1].Trim()
|
||||||
|
if ($hwnd -eq 0) { throw "target reported a null window handle" }
|
||||||
|
Write-Host " target pid=$tid hwnd=$hwnd title=[$($tp.MainWindowTitle)]"
|
||||||
|
Assert-TargetForeground $hwnd
|
||||||
|
Write-Host " target is foreground: yes"
|
||||||
|
|
||||||
|
Write-Host "=== 2. V6: four CJK BMP characters (Ni Hao Shi Jie) ==="
|
||||||
|
$cjk = @(0x4F60, 0x597D, 0x4E16, 0x754C)
|
||||||
|
Send-Units $cjk $hwnd
|
||||||
|
$got6 = Read-Received
|
||||||
|
$exp6 = CodePointsToString $cjk
|
||||||
|
Write-Host (" received [{0}] expected [{1}]" -f (Show-Text $got6), (Show-Text $exp6))
|
||||||
|
if ($got6 -ne $exp6) { throw "V6 FAIL" }
|
||||||
|
Write-Host " V6 PASS - BMP CJK arrived intact through v3 Text + KEYEVENTF_UNICODE"
|
||||||
|
|
||||||
|
Write-Host "=== 3. V7: exclaim + emoji U+1F600 (UTF-16 surrogate pair) appended ==="
|
||||||
|
$mix = @(0x0021, 0xD83D, 0xDE00)
|
||||||
|
Send-Units $mix $hwnd
|
||||||
|
$got7 = Read-Received
|
||||||
|
$exp7 = $exp6 + (CodePointsToString $mix)
|
||||||
|
Write-Host (" received len={0} [{1}]" -f $got7.Length, (Show-Text $got7))
|
||||||
|
Write-Host (" expected len={0} [{1}]" -f $exp7.Length, (Show-Text $exp7))
|
||||||
|
if ($got7 -ne $exp7) { throw "V7 FAIL - mismatch" }
|
||||||
|
if ($got7.Contains([char]0xFFFD)) { throw "V7 FAIL - U+FFFD present: pair got split" }
|
||||||
|
Write-Host " V7 PASS - surrogate pair survived as one character, no replacement char"
|
||||||
|
|
||||||
|
Write-Host "=== 4. negative guard: missing --units must NOT inject anything ==="
|
||||||
|
$before = Read-Received
|
||||||
|
$exe = "$repo\target\debug\examples\text_check.exe"
|
||||||
|
# Omit --units entirely (passing an empty string would be eaten by PowerShell
|
||||||
|
# argument handling and parsed as the next flag). viewer-core's parse_units
|
||||||
|
# rejects the degenerate input before any session is even opened.
|
||||||
|
# No 2>&1 here: viewer-core logs go to stderr by design; merging them turns
|
||||||
|
# every normal log line into a PowerShell error record.
|
||||||
|
& $exe --port 47800 --code t --delay 100 --hold 100 | Out-Null
|
||||||
|
$emptyCode = $LASTEXITCODE
|
||||||
|
Write-Host " exit=$emptyCode (expected non-zero: tool rejects empty --units)"
|
||||||
|
if ($emptyCode -eq 0) { throw "FAIL - empty units should be rejected by the tool" }
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
$after = Read-Received
|
||||||
|
if ($after -ne $before) { throw "FAIL - target text changed unexpectedly: [$after]" }
|
||||||
|
Write-Host " guard PASS - no injection happened, target text unchanged"
|
||||||
|
|
||||||
|
Write-Host "=== 5. stop target ==="
|
||||||
|
New-Item -ItemType File -Force -Path $stopFile | Out-Null
|
||||||
|
$tries = 0
|
||||||
|
while ((Get-Process -Id $tid -ErrorAction SilentlyContinue) -and $tries -lt 40) {
|
||||||
|
Start-Sleep -Milliseconds 150; $tries++
|
||||||
|
}
|
||||||
|
Write-Host "=== PROOF COMPLETE: final target text = [$(Show-Text (Read-Received))] ==="
|
||||||
110
tools/text_target.ps1
Normal file
110
tools/text_target.ps1
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# 2b V6/V7 proof target: a dedicated WinForms window we own.
|
||||||
|
# ASCII-only source (PS 5.1 decodes BOM-less .ps1 as GBK; see personal lessons).
|
||||||
|
#
|
||||||
|
# Launched by text_injection_proof.ps1. Shows a topmost empty TextBox, writes
|
||||||
|
# its current text to ResultFile (UTF-8 with BOM) on every change, exits when
|
||||||
|
# StopFile appears. The proof injects through the remote-control pipeline and
|
||||||
|
# polls ResultFile - so "what arrived on the controlled desktop" is reported
|
||||||
|
# by the receiving application itself, not by a screenshot or OCR.
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$PidFile,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ResultFile,
|
||||||
|
[Parameter(Mandatory = $true)][string]$StopFile,
|
||||||
|
# SendInput lands on whatever is FOREGROUND. When the injector is driven
|
||||||
|
# from another machine (phone -> agent -> this desktop), nothing in this
|
||||||
|
# process keeps our window on top, so an unrelated window could eat the
|
||||||
|
# injection. With this switch the form re-asserts foreground every tick and
|
||||||
|
# the caller still asserts foreground before injecting.
|
||||||
|
[switch]$GrabForeground
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
# Event handler scriptblocks resolve script-scoped vars; make that explicit
|
||||||
|
# instead of relying on the param locals staying on a visible scope chain.
|
||||||
|
$script:PidFile = $PidFile
|
||||||
|
$script:ResultFile = $ResultFile
|
||||||
|
$script:StopFile = $StopFile
|
||||||
|
$script:lastWritten = $null
|
||||||
|
|
||||||
|
$form = New-Object System.Windows.Forms.Form
|
||||||
|
$form.Text = "RemoteTextTarget"
|
||||||
|
$form.ClientSize = New-Object System.Drawing.Size(420, 140)
|
||||||
|
$form.TopMost = $true
|
||||||
|
$form.StartPosition = "Manual"
|
||||||
|
$form.Location = New-Object System.Drawing.Point(80, 80)
|
||||||
|
|
||||||
|
$tb = New-Object System.Windows.Forms.TextBox
|
||||||
|
$tb.Multiline = $true
|
||||||
|
$tb.Font = New-Object System.Drawing.Font("Segoe UI", 16.0)
|
||||||
|
$tb.Dock = "Fill"
|
||||||
|
$form.Controls.Add($tb)
|
||||||
|
|
||||||
|
$script:GrabForeground = $GrabForeground
|
||||||
|
if ($script:GrabForeground) {
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class OwnForeground {
|
||||||
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||||
|
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, IntPtr pid);
|
||||||
|
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
|
||||||
|
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
|
||||||
|
|
||||||
|
// Deliberately NO keybd_event(VK_MENU). The usual 'tap ALT to beat the
|
||||||
|
// foreground lock' trick corrupts exactly what we measure: after ALT, Windows
|
||||||
|
// treats the next character as a menu MNEMONIC, so the first injected
|
||||||
|
// character is eaten. Measured 2026-10-30: ALT re-asserting every 60ms turned
|
||||||
|
// three injected characters into only 'b' in the target.
|
||||||
|
// Attaching input queues is a lock workaround that synthesises no key input.
|
||||||
|
public static bool Claim(IntPtr h) {
|
||||||
|
uint fgThread = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);
|
||||||
|
uint myThread = GetCurrentThreadId();
|
||||||
|
AttachThreadInput(myThread, fgThread, true);
|
||||||
|
bool ok = SetForegroundWindow(h);
|
||||||
|
AttachThreadInput(myThread, fgThread, false);
|
||||||
|
return ok && GetForegroundWindow() == h;
|
||||||
|
}
|
||||||
|
public static IntPtr Now() { return GetForegroundWindow(); }
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
$timer = New-Object System.Windows.Forms.Timer
|
||||||
|
$timer.Interval = 150
|
||||||
|
$timer.Add_Tick({
|
||||||
|
if (Test-Path $script:StopFile) {
|
||||||
|
$form.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ($script:GrabForeground) {
|
||||||
|
# Re-assert only when actually lost (unconditional claiming every tick
|
||||||
|
# would fight the user for the desktop outside a proof run).
|
||||||
|
if ([OwnForeground]::Now() -ne $form.Handle) {
|
||||||
|
[void]$form.Activate()
|
||||||
|
[void][OwnForeground]::Claim($form.Handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($tb.Text -ne $script:lastWritten) {
|
||||||
|
$script:lastWritten = $tb.Text
|
||||||
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
[System.IO.File]::WriteAllText($script:ResultFile, $tb.Text, $enc)
|
||||||
|
}
|
||||||
|
if (-not $tb.Focused) { [void]$tb.Focus() }
|
||||||
|
})
|
||||||
|
|
||||||
|
$form.Add_Shown({
|
||||||
|
$form.Activate()
|
||||||
|
[void]$tb.Focus()
|
||||||
|
# Report BOTH pid and window handle: a proof caller resolving the handle via
|
||||||
|
# Get-Process races with window creation and can read 0. Handle is stable
|
||||||
|
# here because the form is realized by the time Shown fires.
|
||||||
|
[System.IO.File]::WriteAllText($script:PidFile, "$PID`n" + $form.Handle.ToInt64())
|
||||||
|
})
|
||||||
|
|
||||||
|
$timer.Start()
|
||||||
|
[void]$form.ShowDialog()
|
||||||
|
$timer.Stop()
|
||||||
2
vendor/nvenc/.gitignore
vendored
Normal file
2
vendor/nvenc/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
/.vscode
|
||||||
63
vendor/nvenc/Cargo.toml
vendored
Normal file
63
vendor/nvenc/Cargo.toml
vendored
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||||
|
#
|
||||||
|
# When uploading crates to the registry Cargo will automatically
|
||||||
|
# "normalize" Cargo.toml files for maximal compatibility
|
||||||
|
# with all versions of Cargo and also rewrite `path` dependencies
|
||||||
|
# to registry (e.g., crates.io) dependencies.
|
||||||
|
#
|
||||||
|
# If you are reading this file be aware that the original Cargo.toml
|
||||||
|
# will likely look very different (and much more reasonable).
|
||||||
|
# See Cargo.toml.orig for the original contents.
|
||||||
|
|
||||||
|
[package]
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.85.1"
|
||||||
|
name = "nvenc"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["AlsoSylv"]
|
||||||
|
build = false
|
||||||
|
autolib = false
|
||||||
|
autobins = false
|
||||||
|
autoexamples = false
|
||||||
|
autotests = false
|
||||||
|
autobenches = false
|
||||||
|
description = "Wrapper for the nvidia NVENC API"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = [
|
||||||
|
"nvidia",
|
||||||
|
"graphics",
|
||||||
|
"media",
|
||||||
|
"video",
|
||||||
|
"ffi",
|
||||||
|
]
|
||||||
|
categories = [
|
||||||
|
"multimedia",
|
||||||
|
"external-ffi-bindings",
|
||||||
|
]
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/AlsoSylv/nvenc"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "nvenc"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
# 上游 README 的示例是已过时的 Linux GLX 用法(Session<NeedsConfig> 等类型已不存在),
|
||||||
|
# 直接跑 doctest 会失败。本项目只做 vendor 补丁、不维护文档示例,故关闭。
|
||||||
|
doctest = false
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "simple_encode"
|
||||||
|
path = "examples/simple_encode.rs"
|
||||||
|
|
||||||
|
[dependencies.bitfields]
|
||||||
|
version = "1.0.2"
|
||||||
|
|
||||||
|
[dependencies.libloading]
|
||||||
|
version = "0.8.9"
|
||||||
|
|
||||||
|
[target."cfg(windows)".dependencies.windows]
|
||||||
|
version = "0.62"
|
||||||
|
features = [
|
||||||
|
"Win32_Graphics_Dxgi_Common",
|
||||||
|
"Win32_Graphics_Direct3D",
|
||||||
|
"Win32_Graphics_Direct3D11",
|
||||||
|
]
|
||||||
24
vendor/nvenc/Cargo.toml.orig
generated
vendored
Normal file
24
vendor/nvenc/Cargo.toml.orig
generated
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "nvenc"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
authors = ["AlsoSylv"]
|
||||||
|
description = "Wrapper for the nvidia NVENC API"
|
||||||
|
rust-version = "1.85.1"
|
||||||
|
readme = "README.md"
|
||||||
|
repository = "https://github.com/AlsoSylv/nvenc"
|
||||||
|
license = "MIT"
|
||||||
|
keywords = ["nvidia", "graphics", "media", "video", "ffi"]
|
||||||
|
categories = ["multimedia", "external-ffi-bindings"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libloading = "0.8.9"
|
||||||
|
bitfields = "1.0.2"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies.windows]
|
||||||
|
version = "0.62"
|
||||||
|
features = [
|
||||||
|
"Win32_Graphics_Dxgi_Common",
|
||||||
|
"Win32_Graphics_Direct3D",
|
||||||
|
"Win32_Graphics_Direct3D11",
|
||||||
|
]
|
||||||
27
vendor/nvenc/LICENSE.txt
vendored
Normal file
27
vendor/nvenc/LICENSE.txt
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
Notice Regarding Standards. I do not provide a license or sublicense to
|
||||||
|
any Intellectual Property Rights relating to any standards, including but not
|
||||||
|
limited to any audio and/or video codec technologies such as MPEG-2, MPEG-4;
|
||||||
|
AVC/H.264; HEVC/H.265; AAC decode/FFMPEG; AAC encode/FFMPEG; VC-1; and MP3
|
||||||
|
(collectively, the "Media Technologies"). For clarity, you will pay any
|
||||||
|
royalties due for such third party technologies, which may include the Media
|
||||||
|
Technologies that are owed as a result of me providing the Software to you.
|
||||||
|
|
||||||
|
Copyright 2025 AlsoSylv
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
34
vendor/nvenc/README.md
vendored
Normal file
34
vendor/nvenc/README.md
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
### If you like my work and want to support what I do, support me on Ko-Fi 💜!
|
||||||
|
[](https://ko-fi.com/cyberite)
|
||||||
|
---
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
|
The following example is for Linux GLX
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use nvenc::{session::Session, encoder::Encoder};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Setup GLX context
|
||||||
|
let session: Session<NeedsConfig> = Session::open_gl();
|
||||||
|
let (session, config): (Session<NeedsInit>, NVencPresetConfig)
|
||||||
|
= session.get_preset_config(
|
||||||
|
NV_ENC_CODEC_H264_GUID,
|
||||||
|
NV_ENC_PRESET_P3_GUID,
|
||||||
|
NVencTuningInfo::LowLatency
|
||||||
|
);
|
||||||
|
let init_params = nvenc::session::InitParams {
|
||||||
|
encode_guid: NV_ENC_H264_GUID,
|
||||||
|
preset_guid: NV_ENC_PRESET_P3_GUID,
|
||||||
|
resolution: [1920, 1080],
|
||||||
|
aspect_ratio: [16, 9],
|
||||||
|
frame_rate: [30, 1],
|
||||||
|
tuning_info: NVencTuningInfo::LowLatency,
|
||||||
|
buffer_format: NVencBufferFormat::ARGB,
|
||||||
|
encode_config: &mut config.preset_cfg,
|
||||||
|
enable_ptd: true,
|
||||||
|
};
|
||||||
|
let encoder: Encoder = session.init(init_params);
|
||||||
|
}
|
||||||
|
```
|
||||||
203
vendor/nvenc/examples/simple_encode.rs
vendored
Normal file
203
vendor/nvenc/examples/simple_encode.rs
vendored
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use nvenc::bitstream::BitStream;
|
||||||
|
use nvenc::session::InitParams;
|
||||||
|
use nvenc::{
|
||||||
|
session::Session,
|
||||||
|
sys::guids::{NV_ENC_CODEC_H264_GUID, NV_ENC_PRESET_P3_GUID},
|
||||||
|
};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11Texture2D};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn setup() -> ID3D11Device {
|
||||||
|
let dxgi_factory: windows::Win32::Graphics::Dxgi::IDXGIFactory =
|
||||||
|
unsafe { windows::Win32::Graphics::Dxgi::CreateDXGIFactory() }.unwrap();
|
||||||
|
let dxgi_adapter = unsafe { dxgi_factory.EnumAdapters(0) }.unwrap();
|
||||||
|
|
||||||
|
let desc = unsafe { dxgi_adapter.GetDesc() }.unwrap();
|
||||||
|
println!("{:?}", windows::core::HSTRING::from_wide(&desc.Description));
|
||||||
|
|
||||||
|
let mut device = None;
|
||||||
|
let mut device_context = None;
|
||||||
|
unsafe {
|
||||||
|
use windows::Win32::{
|
||||||
|
Foundation::HMODULE,
|
||||||
|
Graphics::{
|
||||||
|
Direct3D::{D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0},
|
||||||
|
Direct3D11::{D3D11_CREATE_DEVICE_FLAG, D3D11_SDK_VERSION, D3D11CreateDevice},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
D3D11CreateDevice(
|
||||||
|
&dxgi_adapter,
|
||||||
|
D3D_DRIVER_TYPE_UNKNOWN,
|
||||||
|
HMODULE(std::ptr::null_mut()),
|
||||||
|
D3D11_CREATE_DEVICE_FLAG(0),
|
||||||
|
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||||
|
D3D11_SDK_VERSION,
|
||||||
|
Some(&raw mut device),
|
||||||
|
None,
|
||||||
|
Some(&raw mut device_context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.unwrap();
|
||||||
|
let device = device.unwrap();
|
||||||
|
let _device_context = device_context.unwrap();
|
||||||
|
|
||||||
|
device
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn image(device: &ID3D11Device) -> ID3D11Texture2D {
|
||||||
|
let data: [u8; 1920 * 4] = std::array::from_fn(|idx| match idx % 4 {
|
||||||
|
0 => 255,
|
||||||
|
1 => 0,
|
||||||
|
2 => 0,
|
||||||
|
3 => 255,
|
||||||
|
_ => unreachable!(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut texture = None;
|
||||||
|
let desc = windows::Win32::Graphics::Direct3D11::D3D11_TEXTURE2D_DESC {
|
||||||
|
Width: 1920,
|
||||||
|
Height: 1080,
|
||||||
|
MipLevels: 1,
|
||||||
|
ArraySize: 1,
|
||||||
|
Format: windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_R8G8B8A8_UNORM_SRGB,
|
||||||
|
SampleDesc: windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC {
|
||||||
|
Count: 1,
|
||||||
|
Quality: 0,
|
||||||
|
},
|
||||||
|
Usage: windows::Win32::Graphics::Direct3D11::D3D11_USAGE_DEFAULT,
|
||||||
|
BindFlags: windows::Win32::Graphics::Direct3D11::D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||||
|
CPUAccessFlags: windows::Win32::Graphics::Dxgi::Common::DXGI_CPU_ACCESS_NONE,
|
||||||
|
MiscFlags: 0,
|
||||||
|
};
|
||||||
|
let data = windows::Win32::Graphics::Direct3D11::D3D11_SUBRESOURCE_DATA {
|
||||||
|
pSysMem: data.as_ptr() as _,
|
||||||
|
SysMemPitch: 1920 * 4,
|
||||||
|
SysMemSlicePitch: 0,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
device.CreateTexture2D(
|
||||||
|
&raw const desc,
|
||||||
|
Some(&raw const data),
|
||||||
|
Some(&raw mut texture),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.unwrap();
|
||||||
|
texture.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let device = setup();
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
compile_error!("Linux is currently unsupported in this example");
|
||||||
|
#[cfg(windows)]
|
||||||
|
let session: Session<nvenc::session::NeedsConfig> = Session::open_dx(&device).unwrap();
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.get_encode_codecs()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&NV_ENC_CODEC_H264_GUID)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.get_encode_presets(NV_ENC_CODEC_H264_GUID)
|
||||||
|
.unwrap()
|
||||||
|
.contains(&NV_ENC_PRESET_P3_GUID)
|
||||||
|
);
|
||||||
|
let (session, mut config) = session
|
||||||
|
.get_encode_preset_config_ex(
|
||||||
|
NV_ENC_CODEC_H264_GUID,
|
||||||
|
NV_ENC_PRESET_P3_GUID,
|
||||||
|
nvenc::sys::enums::NVencTuningInfo::LowLatency,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let texture = image(&device);
|
||||||
|
|
||||||
|
config.preset_cfg.rc_params.rate_control_mode = nvenc::sys::enums::NVencParamsRcMode::VBR;
|
||||||
|
config.preset_cfg.rc_params.average_bit_rate = 10_000_000;
|
||||||
|
config.preset_cfg.gop_len = 0xffffffff;
|
||||||
|
config.preset_cfg.frame_interval_p = 1;
|
||||||
|
println!("P-Frames: {}", config.preset_cfg.frame_interval_p);
|
||||||
|
println!("Bitrate: {}", config.preset_cfg.rc_params.average_bit_rate);
|
||||||
|
|
||||||
|
let init_params = InitParams {
|
||||||
|
encode_guid: NV_ENC_CODEC_H264_GUID,
|
||||||
|
preset_guid: NV_ENC_PRESET_P3_GUID,
|
||||||
|
aspect_ratio: [16, 9],
|
||||||
|
encode_config: &mut config.preset_cfg,
|
||||||
|
tuning_info: nvenc::sys::enums::NVencTuningInfo::LowLatency,
|
||||||
|
buffer_format: nvenc::sys::enums::NVencBufferFormat::ARGB,
|
||||||
|
frame_rate: [30, 1],
|
||||||
|
resolution: [1920, 1080],
|
||||||
|
enable_ptd: true,
|
||||||
|
max_encoder_resolution: [0, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("FPS: 30");
|
||||||
|
println!("Len: 600 frames");
|
||||||
|
println!("Expected time: {}", 600 / 30);
|
||||||
|
println!("Codec: H264");
|
||||||
|
println!("Tuning Info {:?}:", init_params.tuning_info);
|
||||||
|
|
||||||
|
let encoder = session.init_encoder(init_params).unwrap();
|
||||||
|
let registered = encoder
|
||||||
|
.register_resource_dx11(&texture, nvenc::sys::enums::NVencBufferFormat::ARGB, 0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (processed, to_use) = std::sync::mpsc::sync_channel::<BitStream>(2);
|
||||||
|
let (re_use, to_process) = std::sync::mpsc::sync_channel::<BitStream>(2);
|
||||||
|
processed
|
||||||
|
.send(encoder.create_bitstream_buffer().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
processed
|
||||||
|
.send(encoder.create_bitstream_buffer().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let handle = std::thread::spawn({
|
||||||
|
move || {
|
||||||
|
let mut out = File::create("output.h264").unwrap();
|
||||||
|
|
||||||
|
while let Ok(output) = to_process.recv() {
|
||||||
|
let lock = output.try_lock(true).unwrap();
|
||||||
|
out.write_all(lock.as_slice()).unwrap();
|
||||||
|
drop(lock);
|
||||||
|
if let Err(_) = processed.send(output) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let instant = Instant::now();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < 600 {
|
||||||
|
if let Ok(output) = to_use.recv() {
|
||||||
|
encoder
|
||||||
|
.encode_picture(
|
||||||
|
®istered,
|
||||||
|
&output,
|
||||||
|
i,
|
||||||
|
instant.duration_since(Instant::now()).as_millis() as u64,
|
||||||
|
nvenc::sys::enums::NVencBufferFormat::ARGB,
|
||||||
|
nvenc::sys::enums::NVencPicStruct::Frame,
|
||||||
|
nvenc::sys::enums::NVencPicType::P,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
re_use.send(output).unwrap();
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(to_use);
|
||||||
|
drop(re_use);
|
||||||
|
handle.join().unwrap();
|
||||||
|
}
|
||||||
86
vendor/nvenc/src/lib.rs
vendored
Normal file
86
vendor/nvenc/src/lib.rs
vendored
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
#![doc = include_str!("../README.md")]
|
||||||
|
|
||||||
|
use std::{mem::MaybeUninit, sync::OnceLock};
|
||||||
|
|
||||||
|
use libloading::Library;
|
||||||
|
|
||||||
|
use crate::sys::{
|
||||||
|
function_table::NVencFunctionList,
|
||||||
|
result::{NVencError, NVencResult},
|
||||||
|
version::NVENC_API_LIST_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
mod safe;
|
||||||
|
pub use safe::*;
|
||||||
|
/// Contains the original C Types, functions, enums, and constants
|
||||||
|
pub mod sys;
|
||||||
|
|
||||||
|
#[cfg(all(target_arch = "x86_64", target_os = "windows"))]
|
||||||
|
pub const NVENC_DLL: &str = "nvEncodeAPI64.dll";
|
||||||
|
#[cfg(all(target_arch = "x86", target_os = "windows"))]
|
||||||
|
pub const NVENC_DLL: &str = "nvEncodeAPI.dll";
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
/// Platform specific DLL name, `libnvidia-encode.so.1` on Linux, `nvEncodeAPI64.dll` on Windows
|
||||||
|
pub const NVENC_DLL: &str = "libnvidia-encode.so.1";
|
||||||
|
|
||||||
|
#[cfg(all(target_arch = "x86", windows))]
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! stdcall {
|
||||||
|
(fn $args:tt $(-> $ret:tt)?) => { unsafe extern "stdcall" fn $args $(-> $ret)? };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[cfg(not(all(target_arch = "x86", windows)))]
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! stdcall {
|
||||||
|
(fn $args:tt $(-> $ret:ty)?) => { unsafe extern "C" fn $args $(-> $ret)? };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) static LIBRARY: OnceLock<NVENCLibrary> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Struct containing the library handle, init and get_max_version number functions
|
||||||
|
pub struct NVENCLibrary {
|
||||||
|
#[allow(unused)]
|
||||||
|
// Lib must stay alive for nvenc to be used
|
||||||
|
lib: Library,
|
||||||
|
init: stdcall!(fn(list: *mut MaybeUninit<NVencFunctionList>) -> NVencResult),
|
||||||
|
get_max_version: stdcall!(fn(version: *mut u32) -> NVencResult),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to init NVENC, returns an error if the library or loading functions cannot be found
|
||||||
|
pub fn nvenc_init() -> Result<&'static NVENCLibrary, libloading::Error> {
|
||||||
|
if let Some(lib) = LIBRARY.get() {
|
||||||
|
Ok(lib)
|
||||||
|
} else {
|
||||||
|
let lib = unsafe { Library::new(NVENC_DLL) }?;
|
||||||
|
let init: stdcall!(fn(function_list: *mut MaybeUninit<NVencFunctionList>) -> NVencResult) =
|
||||||
|
*unsafe { lib.get(b"NvEncodeAPICreateInstance") }?;
|
||||||
|
let get_max_version: stdcall!(fn(version: *mut u32) -> NVencResult) =
|
||||||
|
*unsafe { lib.get(b"NvEncodeAPIGetMaxSupportedVersion") }?;
|
||||||
|
|
||||||
|
Ok(LIBRARY.get_or_init(|| NVENCLibrary {
|
||||||
|
lib,
|
||||||
|
init,
|
||||||
|
get_max_version,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NVENCLibrary {
|
||||||
|
/// Create a new instance of the NVencFunctionList,
|
||||||
|
pub fn create_instance(&self) -> Result<NVencFunctionList, NVencError> {
|
||||||
|
let mut list = MaybeUninit::zeroed();
|
||||||
|
unsafe { *(list.as_mut_ptr() as *mut u32) = NVENC_API_LIST_VERSION };
|
||||||
|
unsafe { (self.init)(&raw mut list) }.into_error().unwrap();
|
||||||
|
Ok(unsafe { list.assume_init() })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_max_version(&self) -> Result<u32, NVencError> {
|
||||||
|
let mut version = 0;
|
||||||
|
unsafe { (self.get_max_version)(&raw mut version) }.into_error()?;
|
||||||
|
let minor = version & 0x0F;
|
||||||
|
let version = (version >> 4) | minor << 28;
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
vendor/nvenc/src/safe/bitstream.rs
vendored
Normal file
53
vendor/nvenc/src/safe/bitstream.rs
vendored
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
use std::{ffi::c_void, sync::Arc};
|
||||||
|
|
||||||
|
use crate::{safe::encoder::EncoderInternal, sys::result::NVencError};
|
||||||
|
|
||||||
|
pub struct BitStream {
|
||||||
|
pub(crate) buffer: *mut c_void,
|
||||||
|
pub(crate) encoder: Arc<EncoderInternal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for BitStream {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
println!("Dropping bitstream buffer");
|
||||||
|
self.encoder.destroy_bitstream_buffer(self.buffer).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for BitStream {}
|
||||||
|
|
||||||
|
impl BitStream {
|
||||||
|
/// Attempts to lock the bit stream, if `wait` is true it will wait
|
||||||
|
/// otherwise a `LockBusy` Error may be returned, in which case the
|
||||||
|
/// client should retry in a few milliseconds
|
||||||
|
pub fn try_lock(&self, wait: bool) -> Result<BitStreamLockGuard<'_>, NVencError> {
|
||||||
|
let lock = self.encoder.lock_bit_stream_buffer(self.buffer, wait)?;
|
||||||
|
Ok(BitStreamLockGuard {
|
||||||
|
buffer: self,
|
||||||
|
data_ptr: lock.bitstream_buffer,
|
||||||
|
data_len: lock.bitstream_size_in_bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds a reference to the `BitStream` and holds the data and associated fields
|
||||||
|
pub struct BitStreamLockGuard<'a> {
|
||||||
|
buffer: &'a BitStream,
|
||||||
|
data_ptr: *mut c_void,
|
||||||
|
data_len: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BitStreamLockGuard<'_> {
|
||||||
|
pub fn as_slice(&self) -> &[u8] {
|
||||||
|
unsafe { std::slice::from_raw_parts(self.data_ptr as _, self.data_len as _) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Drop for BitStreamLockGuard<'a> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.buffer
|
||||||
|
.encoder
|
||||||
|
.unlock_bit_stream_buffer(self.buffer.buffer)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
517
vendor/nvenc/src/safe/encoder.rs
vendored
Normal file
517
vendor/nvenc/src/safe/encoder.rs
vendored
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
use std::ffi::{CStr, c_void};
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::mem::ManuallyDrop;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::{mem::MaybeUninit, ptr::NonNull};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::Graphics::Direct3D11::ID3D11Texture2D;
|
||||||
|
|
||||||
|
use crate::input_buffer::InputBuffer;
|
||||||
|
use crate::safe::bitstream::BitStream;
|
||||||
|
use crate::sys::enums::{
|
||||||
|
NVencBufferFormat, NVencBufferUsage, NVencInputResourceType, NVencMemoryHeap, NVencPicStruct,
|
||||||
|
NVencPicType,
|
||||||
|
};
|
||||||
|
use crate::sys::structs::{
|
||||||
|
Guid, NV_ENC_CREATE_INPUT_BUFFER_VER, NV_ENC_LOCK_INPUT_BUFFER_VER, NVencCodecPicParams,
|
||||||
|
NVencCreateInputBuffer, NVencLockInputBuffer, NVencPicParamsH264, NVencRegisterResource,
|
||||||
|
};
|
||||||
|
use crate::sys::{
|
||||||
|
enums::{NVencPicFlags, NVencTuningInfo},
|
||||||
|
function_table::NVencFunctionList,
|
||||||
|
result::NVencError,
|
||||||
|
structs::{
|
||||||
|
NV_ENC_CONFIG_VER, NV_ENC_CREATE_BITSTREAM_BUFFER_VER, NV_ENC_LOCK_BITSTREAM_VER,
|
||||||
|
NV_ENC_MAP_INPUT_RESOURCE_VER, NV_ENC_PIC_PARAMS_VER, NV_ENC_PRESET_CONFIG_VER,
|
||||||
|
NV_ENC_REGISTER_RESOURCE_VER, NVencCreateBitstreamBuffer, NVencInitializeParams,
|
||||||
|
NVencLockBitStream, NVencPicParams, NVencPresetConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Encoder {
|
||||||
|
pub(crate) encoder: Arc<EncoderInternal>,
|
||||||
|
pub(crate) marker: PhantomData<*mut ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait EncoderInput {
|
||||||
|
#[doc(hidden)]
|
||||||
|
fn as_ptr(&self) -> *mut c_void;
|
||||||
|
|
||||||
|
fn pitch(&self) -> u32;
|
||||||
|
fn height(&self) -> u32;
|
||||||
|
fn width(&self) -> u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncoderInput for RegisteredResource {
|
||||||
|
fn as_ptr(&self) -> *mut c_void {
|
||||||
|
self.mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pitch(&self) -> u32 {
|
||||||
|
self.pitch
|
||||||
|
}
|
||||||
|
|
||||||
|
fn height(&self) -> u32 {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
fn width(&self) -> u32 {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncoderInput for InputBuffer {
|
||||||
|
fn as_ptr(&self) -> *mut c_void {
|
||||||
|
self.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pitch(&self) -> u32 {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
fn height(&self) -> u32 {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
fn width(&self) -> u32 {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encoder {
|
||||||
|
#[cfg(windows)]
|
||||||
|
/// Registers a DX11 texture as an nvenc input resource
|
||||||
|
pub fn register_resource_dx11(
|
||||||
|
&self,
|
||||||
|
resource: &ID3D11Texture2D,
|
||||||
|
format: NVencBufferFormat,
|
||||||
|
pitch: u32,
|
||||||
|
) -> Result<RegisteredResource, NVencError> {
|
||||||
|
self.encoder.register_resource_dx(resource, format, pitch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `resource` must not be null
|
||||||
|
/// `ty` and `format` should match the original buffer
|
||||||
|
pub unsafe fn register_resource_raw(
|
||||||
|
&self,
|
||||||
|
resource: *mut c_void,
|
||||||
|
format: NVencBufferFormat,
|
||||||
|
usage: NVencBufferUsage,
|
||||||
|
ty: NVencInputResourceType,
|
||||||
|
resolution: [u32; 2],
|
||||||
|
pitch: u32,
|
||||||
|
) -> Result<*mut c_void, NVencError> {
|
||||||
|
unsafe {
|
||||||
|
self.encoder
|
||||||
|
.register_resource_raw(resource, format, usage, ty, resolution, pitch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes an encoder inout, and outputs a bit stream that can be read back
|
||||||
|
pub fn encode_picture<I: EncoderInput>(
|
||||||
|
&self,
|
||||||
|
input: &I,
|
||||||
|
output: &BitStream,
|
||||||
|
frame_count: usize,
|
||||||
|
timestamp: u64,
|
||||||
|
format: NVencBufferFormat,
|
||||||
|
pic_struct: NVencPicStruct,
|
||||||
|
ty: NVencPicType,
|
||||||
|
codec_params: Option<CodecPicParams>,
|
||||||
|
) -> Result<(), NVencError> {
|
||||||
|
self.encoder.encode_picture(
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
frame_count,
|
||||||
|
timestamp,
|
||||||
|
format,
|
||||||
|
pic_struct,
|
||||||
|
ty,
|
||||||
|
codec_params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the encoder session
|
||||||
|
pub fn end_encode(&self) -> Result<(), NVencError> {
|
||||||
|
self.encoder.end_encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a bitstream bfufer that can be used for output storage
|
||||||
|
pub fn create_bitstream_buffer(&self) -> Result<BitStream, NVencError> {
|
||||||
|
self.encoder.create_bitstream_buffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_input_buffer(
|
||||||
|
&self,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
memory_heap: NVencMemoryHeap,
|
||||||
|
format: NVencBufferFormat,
|
||||||
|
) -> Result<InputBuffer, NVencError> {
|
||||||
|
Ok(InputBuffer {
|
||||||
|
encoder: self.encoder.clone(),
|
||||||
|
buffer: self
|
||||||
|
.encoder
|
||||||
|
.create_input_buffer(width, height, memory_heap, format)?,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct EncoderInternal {
|
||||||
|
pub(super) encoder: NonNull<c_void>,
|
||||||
|
pub(super) function_list: NVencFunctionList,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Nvidia encoder is technically thread-safe
|
||||||
|
unsafe impl Send for EncoderInternal {}
|
||||||
|
|
||||||
|
/// The Nvidia encoder handles syncing via `lock_bit_stream` which is all that is allowed to be Send
|
||||||
|
unsafe impl Sync for EncoderInternal {}
|
||||||
|
|
||||||
|
impl Drop for EncoderInternal {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
println!("Dropping encoder, this should happen last");
|
||||||
|
unsafe { (self.function_list.nvenc_destroy_encoder)(self.encoder.as_ptr()) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncoderInternal {
|
||||||
|
pub(crate) fn get_encode_preset_config_ex(
|
||||||
|
&self,
|
||||||
|
codec: Guid,
|
||||||
|
preset: Guid,
|
||||||
|
tuning_info: NVencTuningInfo,
|
||||||
|
) -> Result<NVencPresetConfig, NVencError> {
|
||||||
|
let mut config: std::mem::MaybeUninit<NVencPresetConfig> = std::mem::MaybeUninit::zeroed();
|
||||||
|
unsafe { &mut *config.as_mut_ptr() }.version = NV_ENC_PRESET_CONFIG_VER;
|
||||||
|
unsafe { &mut *config.as_mut_ptr() }.preset_cfg.version = NV_ENC_CONFIG_VER;
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_get_encode_preset_config_ex)(
|
||||||
|
self.encoder.as_ptr(),
|
||||||
|
codec,
|
||||||
|
preset,
|
||||||
|
tuning_info,
|
||||||
|
&raw mut config,
|
||||||
|
)
|
||||||
|
.into_error()?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(unsafe { config.assume_init() })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn init_encoder(
|
||||||
|
&self,
|
||||||
|
mut init_params: NVencInitializeParams,
|
||||||
|
) -> Result<(), NVencError> {
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_initialize_encoder)(
|
||||||
|
self.encoder.as_ptr(),
|
||||||
|
&raw mut init_params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.into_error()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn register_resource_dx(
|
||||||
|
self: &Arc<EncoderInternal>,
|
||||||
|
resource: &ID3D11Texture2D,
|
||||||
|
format: NVencBufferFormat,
|
||||||
|
pitch: u32,
|
||||||
|
) -> Result<RegisteredResource, NVencError> {
|
||||||
|
use windows::Win32::Graphics::Direct3D11::D3D11_TEXTURE2D_DESC;
|
||||||
|
use windows::core::Interface;
|
||||||
|
|
||||||
|
let mut desc = D3D11_TEXTURE2D_DESC::default();
|
||||||
|
unsafe { resource.GetDesc(&raw mut desc) };
|
||||||
|
|
||||||
|
let registered = unsafe {
|
||||||
|
self.register_resource_raw(
|
||||||
|
resource.as_raw(),
|
||||||
|
NVencBufferFormat::Undefined,
|
||||||
|
NVencBufferUsage::Image,
|
||||||
|
NVencInputResourceType::DirectX,
|
||||||
|
[desc.Width, desc.Height],
|
||||||
|
pitch,
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mapped = unsafe { self.map_input_resource_raw(registered, format)? };
|
||||||
|
|
||||||
|
Ok(RegisteredResource {
|
||||||
|
encoder: self.clone(),
|
||||||
|
pitch,
|
||||||
|
width: desc.Width,
|
||||||
|
height: desc.Height,
|
||||||
|
registered,
|
||||||
|
mapped,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub unsafe fn map_input_resource_raw(
|
||||||
|
&self,
|
||||||
|
resource: *mut c_void,
|
||||||
|
format: NVencBufferFormat,
|
||||||
|
) -> Result<*mut c_void, NVencError> {
|
||||||
|
let mut map: crate::sys::structs::NVencMapInputResource = unsafe { std::mem::zeroed() };
|
||||||
|
map.version = NV_ENC_MAP_INPUT_RESOURCE_VER;
|
||||||
|
map.registered_resource = resource;
|
||||||
|
map.mapped_buffer_format = format;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_map_input_resource)(self.encoder.as_ptr(), &raw mut map)
|
||||||
|
}
|
||||||
|
.into_error()?;
|
||||||
|
|
||||||
|
Ok(map.mapped_resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub unsafe fn register_resource_raw(
|
||||||
|
&self,
|
||||||
|
resource: *mut c_void,
|
||||||
|
format: NVencBufferFormat,
|
||||||
|
usage: NVencBufferUsage,
|
||||||
|
ty: NVencInputResourceType,
|
||||||
|
resolution: [u32; 2],
|
||||||
|
pitch: u32,
|
||||||
|
) -> Result<*mut c_void, NVencError> {
|
||||||
|
let mut register_resource: NVencRegisterResource = unsafe { std::mem::zeroed() };
|
||||||
|
register_resource.buffer_format = format;
|
||||||
|
register_resource.buffer_usage = usage;
|
||||||
|
register_resource.resource_type = ty;
|
||||||
|
register_resource.version = NV_ENC_REGISTER_RESOURCE_VER;
|
||||||
|
register_resource.width = resolution[0];
|
||||||
|
register_resource.height = resolution[1];
|
||||||
|
register_resource.pitch = pitch;
|
||||||
|
register_resource.sub_resource_index = 0;
|
||||||
|
register_resource.resource_to_register = resource;
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_register_resource)(
|
||||||
|
self.encoder.as_ptr(),
|
||||||
|
&raw mut register_resource,
|
||||||
|
)
|
||||||
|
.into_error()
|
||||||
|
}?;
|
||||||
|
Ok(register_resource.registered_resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub unsafe fn unmap_input_resource(
|
||||||
|
&self,
|
||||||
|
mapped_resource: *mut c_void,
|
||||||
|
) -> Result<(), NVencError> {
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_unmap_input_resource)(self.encoder.as_ptr(), mapped_resource)
|
||||||
|
}
|
||||||
|
.into_error()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub unsafe fn unregister_resource(
|
||||||
|
&self,
|
||||||
|
registered_resource: *mut c_void,
|
||||||
|
) -> Result<(), NVencError> {
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_unregister_resource)(
|
||||||
|
self.encoder.as_ptr(),
|
||||||
|
registered_resource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.into_error()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_input_buffer(
|
||||||
|
&self,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
memory_heap: NVencMemoryHeap,
|
||||||
|
format: NVencBufferFormat,
|
||||||
|
) -> Result<*mut c_void, NVencError> {
|
||||||
|
let mut input = NVencCreateInputBuffer {
|
||||||
|
version: NV_ENC_CREATE_INPUT_BUFFER_VER,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
memory_heap,
|
||||||
|
buffer_fmt: format,
|
||||||
|
rsvd: 0,
|
||||||
|
input_buffer: std::ptr::null_mut(),
|
||||||
|
p_sys_mem_buffer: std::ptr::null_mut(),
|
||||||
|
rsvd1: unsafe { std::mem::zeroed() },
|
||||||
|
rsvd2: unsafe { std::mem::zeroed() },
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_create_input_buffer)(self.encoder.as_ptr(), &raw mut input)
|
||||||
|
.into_error()?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(input.input_buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn destroy_input_buffer(&self, buffer: *mut c_void) -> Result<(), NVencError> {
|
||||||
|
unsafe { (self.function_list.nvenc_destroy_input_buffer)(self.encoder.as_ptr(), buffer) }
|
||||||
|
.into_error()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lock_input_buffer(&self, buffer: *mut c_void) -> Result<(*mut c_void, u32), NVencError> {
|
||||||
|
let mut lock = NVencLockInputBuffer {
|
||||||
|
version: NV_ENC_LOCK_INPUT_BUFFER_VER,
|
||||||
|
bitflags: 0,
|
||||||
|
input_buffer: buffer,
|
||||||
|
buffer_data_ptr: std::ptr::null_mut(),
|
||||||
|
pitch: 0,
|
||||||
|
rsvd1: unsafe { std::mem::zeroed() },
|
||||||
|
rsvd2: unsafe { std::mem::zeroed() },
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_lock_input_buffer)(self.encoder.as_ptr(), &raw mut lock)
|
||||||
|
.into_error()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((lock.buffer_data_ptr, lock.pitch))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unlock_input_buffer(&self, locked_buffer: *mut c_void) -> Result<(), NVencError> {
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_unlock_input_buffer)(self.encoder.as_ptr(), locked_buffer)
|
||||||
|
.into_error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_picture<I: EncoderInput>(
|
||||||
|
&self,
|
||||||
|
input: &I,
|
||||||
|
output: &BitStream,
|
||||||
|
frame_count: usize,
|
||||||
|
timestamp: u64,
|
||||||
|
format: NVencBufferFormat,
|
||||||
|
pic_struct: NVencPicStruct,
|
||||||
|
ty: NVencPicType,
|
||||||
|
codec_params: Option<CodecPicParams>,
|
||||||
|
) -> Result<(), NVencError> {
|
||||||
|
let mut params: MaybeUninit<NVencPicParams> = MaybeUninit::zeroed();
|
||||||
|
let sys_codec = match codec_params {
|
||||||
|
None => NVencCodecPicParams { rsvd: [0; 256] },
|
||||||
|
Some(CodecPicParams::H264(codec)) => NVencCodecPicParams {
|
||||||
|
h264_pic_params: ManuallyDrop::new(codec),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.version = NV_ENC_PIC_PARAMS_VER;
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.input_width = input.width();
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.input_height = input.height();
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.input_pitch = input.pitch();
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.output_bitstream = output.buffer;
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.input_buffer = input.as_ptr();
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.buffer_format = format;
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.picture_struct = pic_struct;
|
||||||
|
// [本项目补丁] 只设 picture_type=IDR 不足以让 NVENC 真正输出 IDR 切片,
|
||||||
|
// 必须同时置 encode_pic_flags |= NV_ENC_PIC_FLAG_FORCEIDR。
|
||||||
|
//
|
||||||
|
// 上游 0.1.0 未设置该标志,导致运行期请求关键帧时编码器静默忽略、
|
||||||
|
// 继续输出 P 帧(实测:picture_type=IDR 但输出 NAL type=1)。
|
||||||
|
// 首帧之所以"看起来正常",是因为 frame_idx==0 本来就强制 IDR,与本字段无关。
|
||||||
|
//
|
||||||
|
// 语义区分:IntraRefresh 走 ForceIntra,其余 IDR 类走 ForceIDR。
|
||||||
|
// 必须在 `picture_type = ty` 之前计算,否则 ty 已被移动。
|
||||||
|
let pic_flags = match &ty {
|
||||||
|
NVencPicType::IDR => NVencPicFlags::ForceIDR as u32,
|
||||||
|
NVencPicType::IntraRefresh => NVencPicFlags::ForceIntra as u32,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.picture_type = ty;
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.encode_pic_flags = pic_flags;
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.frame_idx = frame_count as u32;
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.input_time_stamp = timestamp;
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.codec_pic_params = sys_codec;
|
||||||
|
match unsafe {
|
||||||
|
(self.function_list.nvenc_encode_picture)(self.encoder.as_ptr(), &raw mut params)
|
||||||
|
.into_error()
|
||||||
|
} {
|
||||||
|
Err(err) => {
|
||||||
|
let ptr =
|
||||||
|
unsafe { (self.function_list.nvenc_get_last_error)(self.encoder.as_ptr()) };
|
||||||
|
println!("{:?}", unsafe { CStr::from_ptr(ptr) });
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end_encode(&self) -> Result<(), NVencError> {
|
||||||
|
let mut params: MaybeUninit<NVencPicParams> = MaybeUninit::zeroed();
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.version = NV_ENC_PIC_PARAMS_VER;
|
||||||
|
unsafe { &mut *params.as_mut_ptr() }.encode_pic_flags = NVencPicFlags::Eos as u32;
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_encode_picture)(self.encoder.as_ptr(), &raw mut params)
|
||||||
|
.into_error()
|
||||||
|
}?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn lock_bit_stream_buffer(
|
||||||
|
&self,
|
||||||
|
buffer: *mut c_void,
|
||||||
|
wait: bool,
|
||||||
|
) -> Result<NVencLockBitStream, NVencError> {
|
||||||
|
let mut lock: MaybeUninit<NVencLockBitStream> = MaybeUninit::zeroed();
|
||||||
|
unsafe { &mut *lock.as_mut_ptr() }.version = NV_ENC_LOCK_BITSTREAM_VER;
|
||||||
|
unsafe { &mut *lock.as_mut_ptr() }.output_bit_stream = buffer;
|
||||||
|
unsafe { &mut *lock.as_mut_ptr() }
|
||||||
|
.bit_fields
|
||||||
|
.set_do_not_wait(!wait);
|
||||||
|
unsafe { (self.function_list.nvenc_lock_bit_stream)(self.encoder.as_ptr(), &raw mut lock) }
|
||||||
|
.into_error()?;
|
||||||
|
Ok(unsafe { lock.assume_init() })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unlock_bit_stream_buffer(&self, buffer: *mut c_void) -> Result<(), NVencError> {
|
||||||
|
unsafe { (self.function_list.nvenc_unlock_bit_stream)(self.encoder.as_ptr(), buffer) }
|
||||||
|
.into_error()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_bitstream_buffer(self: &Arc<Self>) -> Result<BitStream, NVencError> {
|
||||||
|
let mut params: NVencCreateBitstreamBuffer = unsafe { std::mem::zeroed() };
|
||||||
|
params.version = NV_ENC_CREATE_BITSTREAM_BUFFER_VER;
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_create_bit_stream_buffer)(
|
||||||
|
self.encoder.as_ptr(),
|
||||||
|
&raw mut params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.into_error()?;
|
||||||
|
Ok(BitStream {
|
||||||
|
buffer: params.bitstream_buffer,
|
||||||
|
encoder: self.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn destroy_bitstream_buffer(&self, buffer: *mut c_void) -> Result<(), NVencError> {
|
||||||
|
unsafe {
|
||||||
|
(self.function_list.nvenc_destory_bit_stream_buffer)(self.encoder.as_ptr(), buffer)
|
||||||
|
}
|
||||||
|
.into_error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum CodecPicParams {
|
||||||
|
H264(NVencPicParamsH264),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RegisteredResource {
|
||||||
|
registered: *mut c_void,
|
||||||
|
mapped: *mut c_void,
|
||||||
|
pitch: u32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
encoder: Arc<EncoderInternal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RegisteredResource {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { self.encoder.unmap_input_resource(self.mapped).unwrap() };
|
||||||
|
unsafe { self.encoder.unregister_resource(self.registered).unwrap() };
|
||||||
|
}
|
||||||
|
}
|
||||||
60
vendor/nvenc/src/safe/input_buffer.rs
vendored
Normal file
60
vendor/nvenc/src/safe/input_buffer.rs
vendored
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
use std::{ffi::c_void, sync::Arc};
|
||||||
|
|
||||||
|
use crate::{encoder::EncoderInternal, sys::result::NVencError};
|
||||||
|
|
||||||
|
pub struct InputBuffer {
|
||||||
|
pub(crate) encoder: Arc<EncoderInternal>,
|
||||||
|
pub(crate) buffer: *mut c_void,
|
||||||
|
pub(crate) width: u32,
|
||||||
|
pub(crate) height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputBuffer {
|
||||||
|
pub fn lock(&self) -> Result<InputBufferLock<'_>, NVencError> {
|
||||||
|
let (lock_ptr, pitch) = self.encoder.lock_input_buffer(self.buffer)?;
|
||||||
|
Ok(InputBufferLock {
|
||||||
|
input_buffer: self,
|
||||||
|
lock_ptr,
|
||||||
|
pitch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InputBuffer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.encoder.destroy_input_buffer(self.buffer).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InputBufferLock<'a> {
|
||||||
|
input_buffer: &'a InputBuffer,
|
||||||
|
lock_ptr: *mut c_void,
|
||||||
|
pitch: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputBufferLock<'_> {
|
||||||
|
pub unsafe fn data_ptr(&self) -> *mut u8 {
|
||||||
|
self.lock_ptr as _
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn width(&self) -> u32 {
|
||||||
|
self.input_buffer.width
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height(&self) -> u32 {
|
||||||
|
self.input_buffer.height
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pitch(&self) -> u32 {
|
||||||
|
self.pitch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InputBufferLock<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.input_buffer
|
||||||
|
.encoder
|
||||||
|
.unlock_input_buffer(self.lock_ptr)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
vendor/nvenc/src/safe/mod.rs
vendored
Normal file
8
vendor/nvenc/src/safe/mod.rs
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/// Contains the type for the safe BitStream buffer wrapper
|
||||||
|
pub mod bitstream;
|
||||||
|
/// Wrapper for the NVEncoder after creating an active session
|
||||||
|
pub mod encoder;
|
||||||
|
/// Wrapper around an NVenc input buffer
|
||||||
|
pub mod input_buffer;
|
||||||
|
/// Type-state wrapper around session initialization
|
||||||
|
pub mod session;
|
||||||
198
vendor/nvenc/src/safe/session.rs
vendored
Normal file
198
vendor/nvenc/src/safe/session.rs
vendored
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
use std::{marker::PhantomData, sync::Arc};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
encoder::{Encoder, EncoderInternal},
|
||||||
|
sys::{
|
||||||
|
enums::{NVencBufferFormat, NVencDeviceType, NVencTuningInfo},
|
||||||
|
result::NVencError,
|
||||||
|
structs::{
|
||||||
|
Guid, NV_ENC_INITIALIZE_PARAMS_VER, NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||||
|
NVencConfig, NVencInitializeParams, NVencOpenEncodeSessionExParams, NVencPresetConfig,
|
||||||
|
},
|
||||||
|
version::NVENC_API_VERSION,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Session<T> {
|
||||||
|
encoder: Arc<EncoderInternal>,
|
||||||
|
p: PhantomData<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NeedsConfig;
|
||||||
|
|
||||||
|
pub struct NeedsInit;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::core::Interface;
|
||||||
|
|
||||||
|
impl Session<NeedsConfig> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn open_dx(device: &impl Interface) -> Result<Self, NVencError> {
|
||||||
|
let lib = crate::nvenc_init().unwrap();
|
||||||
|
let function_list = lib.create_instance()?;
|
||||||
|
let mut session_params: NVencOpenEncodeSessionExParams = unsafe { std::mem::zeroed() };
|
||||||
|
session_params.version = NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER;
|
||||||
|
session_params.device_type = NVencDeviceType::DirectX;
|
||||||
|
session_params.api_version = NVENC_API_VERSION;
|
||||||
|
session_params.device = device.as_raw();
|
||||||
|
let mut encoder = std::ptr::null_mut();
|
||||||
|
unsafe {
|
||||||
|
(function_list.nvenc_open_encode_session_ex)(&raw mut session_params, &raw mut encoder)
|
||||||
|
.into_error()
|
||||||
|
}?;
|
||||||
|
assert!(!encoder.is_null());
|
||||||
|
let encoder = EncoderInternal {
|
||||||
|
encoder: unsafe { std::ptr::NonNull::new_unchecked(encoder) },
|
||||||
|
function_list,
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
encoder: Arc::new(encoder),
|
||||||
|
p: PhantomData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_gl() -> Result<Self, NVencError> {
|
||||||
|
let lib = crate::nvenc_init().unwrap();
|
||||||
|
let function_list = lib.create_instance()?;
|
||||||
|
let mut session_params: NVencOpenEncodeSessionExParams = unsafe { std::mem::zeroed() };
|
||||||
|
session_params.version = NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER;
|
||||||
|
session_params.device_type = NVencDeviceType::OpenGL;
|
||||||
|
session_params.api_version = NVENC_API_VERSION;
|
||||||
|
session_params.device = std::ptr::null_mut();
|
||||||
|
|
||||||
|
let mut encoder = std::ptr::null_mut();
|
||||||
|
unsafe {
|
||||||
|
(function_list.nvenc_open_encode_session_ex)(&raw mut session_params, &raw mut encoder)
|
||||||
|
.into_error()
|
||||||
|
}?;
|
||||||
|
assert!(!encoder.is_null());
|
||||||
|
let encoder = EncoderInternal {
|
||||||
|
encoder: unsafe { std::ptr::NonNull::new_unchecked(encoder) },
|
||||||
|
function_list,
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
encoder: Arc::new(encoder),
|
||||||
|
p: PhantomData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_encode_codecs(&self) -> Result<Box<[Guid]>, NVencError> {
|
||||||
|
let mut count = 0;
|
||||||
|
unsafe {
|
||||||
|
(self.encoder.function_list.nvenc_get_encoder_guid_count)(
|
||||||
|
self.encoder.encoder.as_ptr(),
|
||||||
|
&raw mut count,
|
||||||
|
)
|
||||||
|
.into_error()
|
||||||
|
}?;
|
||||||
|
let mut array = vec![Guid::default(); count as usize];
|
||||||
|
unsafe {
|
||||||
|
(self.encoder.function_list.nvenc_get_encoder_guids)(
|
||||||
|
self.encoder.encoder.as_ptr(),
|
||||||
|
array.as_mut_ptr(),
|
||||||
|
count,
|
||||||
|
&raw mut count,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.into_error()?;
|
||||||
|
array.truncate(count as usize);
|
||||||
|
Ok(array.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_encode_presets(&self, codec: Guid) -> Result<Box<[Guid]>, NVencError> {
|
||||||
|
let mut count = 0;
|
||||||
|
unsafe {
|
||||||
|
(self.encoder.function_list.nvenc_get_encode_preset_count)(
|
||||||
|
self.encoder.encoder.as_ptr(),
|
||||||
|
codec.clone(),
|
||||||
|
&raw mut count,
|
||||||
|
)
|
||||||
|
.into_error()
|
||||||
|
}?;
|
||||||
|
let mut array = vec![Guid::default(); count as usize];
|
||||||
|
unsafe {
|
||||||
|
(self.encoder.function_list.nvenc_get_encode_preset_guids)(
|
||||||
|
self.encoder.encoder.as_ptr(),
|
||||||
|
codec,
|
||||||
|
array.as_mut_ptr(),
|
||||||
|
count,
|
||||||
|
&raw mut count,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.into_error()?;
|
||||||
|
array.truncate(count as usize);
|
||||||
|
Ok(array.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_encode_preset_config_ex(
|
||||||
|
self,
|
||||||
|
codec: Guid,
|
||||||
|
preset: Guid,
|
||||||
|
tuning_info: NVencTuningInfo,
|
||||||
|
) -> Result<(Session<NeedsInit>, NVencPresetConfig), NVencError> {
|
||||||
|
self.encoder
|
||||||
|
.get_encode_preset_config_ex(codec, preset, tuning_info)
|
||||||
|
.map(|config| {
|
||||||
|
(
|
||||||
|
Session {
|
||||||
|
encoder: self.encoder,
|
||||||
|
p: PhantomData,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InitParams<'a> {
|
||||||
|
pub encode_guid: Guid,
|
||||||
|
pub preset_guid: Guid,
|
||||||
|
pub resolution: [u32; 2],
|
||||||
|
pub aspect_ratio: [u32; 2],
|
||||||
|
pub frame_rate: [u32; 2],
|
||||||
|
pub tuning_info: NVencTuningInfo,
|
||||||
|
pub buffer_format: NVencBufferFormat,
|
||||||
|
pub encode_config: &'a mut NVencConfig,
|
||||||
|
pub enable_ptd: bool,
|
||||||
|
pub max_encoder_resolution: [u32; 2],
|
||||||
|
// TODO: Support for async encoding and bit fields
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session<NeedsInit> {
|
||||||
|
pub fn init_encoder(self, init_params: InitParams) -> Result<Encoder, NVencError> {
|
||||||
|
// This sucks, lol
|
||||||
|
let raw_init_params = NVencInitializeParams {
|
||||||
|
encode_guid: init_params.encode_guid,
|
||||||
|
preset_guid: init_params.preset_guid,
|
||||||
|
encode_width: init_params.resolution[0],
|
||||||
|
encode_height: init_params.resolution[1],
|
||||||
|
dar_width: init_params.aspect_ratio[0],
|
||||||
|
dar_height: init_params.aspect_ratio[1],
|
||||||
|
frame_rate_num: init_params.frame_rate[0],
|
||||||
|
frame_rate_den: init_params.frame_rate[1],
|
||||||
|
tuning_info: init_params.tuning_info,
|
||||||
|
buffer_format: init_params.buffer_format,
|
||||||
|
encode_config: init_params.encode_config as _,
|
||||||
|
enable_ptd: init_params.enable_ptd as u32,
|
||||||
|
version: NV_ENC_INITIALIZE_PARAMS_VER,
|
||||||
|
enable_encode_async: 0,
|
||||||
|
bit_flags: crate::sys::structs::NVencInitializeParamsBitfields::new(),
|
||||||
|
priv_data_size: 0,
|
||||||
|
rsvd: 0,
|
||||||
|
priv_data: std::ptr::null_mut(),
|
||||||
|
max_encode_width: init_params.max_encoder_resolution[0],
|
||||||
|
max_encode_height: init_params.max_encoder_resolution[1],
|
||||||
|
max_me_hint_counts_per_block: [Default::default(), Default::default()],
|
||||||
|
num_state_buffers: 0,
|
||||||
|
output_stats_level: crate::sys::enums::NVencOutputStatsLevel::None,
|
||||||
|
rsvd1: unsafe { std::mem::zeroed() },
|
||||||
|
rsvd2: unsafe { std::mem::zeroed() },
|
||||||
|
};
|
||||||
|
|
||||||
|
self.encoder.init_encoder(raw_init_params)?;
|
||||||
|
Ok(Encoder {
|
||||||
|
encoder: self.encoder,
|
||||||
|
marker: PhantomData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
481
vendor/nvenc/src/sys/enums.rs
vendored
Normal file
481
vendor/nvenc/src/sys/enums.rs
vendored
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencParamsFrameFieldMode {
|
||||||
|
Frame = 0x01,
|
||||||
|
Field = 0x02,
|
||||||
|
MBAFF = 0x03,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencParamsRcMode {
|
||||||
|
ConstQP = 0x00,
|
||||||
|
VBR = 0x01,
|
||||||
|
CBR = 0x02,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencMultiPass {
|
||||||
|
Disabled = 0x00,
|
||||||
|
TwoPassQuarterResolution = 0x01,
|
||||||
|
TwoPassFullResolution = 0x02,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencStateRestoreType {
|
||||||
|
Full = 0x01,
|
||||||
|
RateControl = 0x02,
|
||||||
|
Encode = 0x03,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencOutputStatsLevel {
|
||||||
|
None = 0,
|
||||||
|
BlockLevel = 1,
|
||||||
|
RowLevel = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencEmphasisMapLevel {
|
||||||
|
Level0 = 0x0,
|
||||||
|
Level1 = 0x1,
|
||||||
|
Level2 = 0x2,
|
||||||
|
Level3 = 0x3,
|
||||||
|
Level4 = 0x4,
|
||||||
|
Level5 = 0x5,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencQPMapMode {
|
||||||
|
Disabled = 0x0,
|
||||||
|
Emphasis = 0x1,
|
||||||
|
Delta = 0x2,
|
||||||
|
/// Currently unsupported as of NVENC 13.0
|
||||||
|
Map = 0x3,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencPicStruct {
|
||||||
|
Frame = 0x01,
|
||||||
|
FieldTopBottom = 0x02,
|
||||||
|
FieldBottomTop = 0x03,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencDisplayPicStruct {
|
||||||
|
Frame = 0x0,
|
||||||
|
FieldTopBottom = 0x1,
|
||||||
|
FieldBottomTop = 0x2,
|
||||||
|
Doubling = 0x3,
|
||||||
|
Tripling = 0x4,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencPicType {
|
||||||
|
P = 0x00,
|
||||||
|
B = 0x01,
|
||||||
|
IDR = 0x03,
|
||||||
|
BI = 0x04,
|
||||||
|
Skipped = 0x05,
|
||||||
|
IntraRefresh = 0x06,
|
||||||
|
NonRefP = 0x07,
|
||||||
|
Switch = 0x08,
|
||||||
|
UNKNOWN = 0xFF,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencMVPrecision {
|
||||||
|
Default = 0x00,
|
||||||
|
FullPel = 0x01,
|
||||||
|
HalfPel = 0x02,
|
||||||
|
QuarterPel = 0x03,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencBufferFormat {
|
||||||
|
Undefined = 0x00000000,
|
||||||
|
NV12 = 0x00000001,
|
||||||
|
YV12 = 0x00000010,
|
||||||
|
IYUV = 0x00000100,
|
||||||
|
YUV444 = 0x00001000,
|
||||||
|
YUV420_10Bit = 0x00010000,
|
||||||
|
YUV444_10Bit = 0x00100000,
|
||||||
|
ARGB = 0x01000000,
|
||||||
|
ARGB10 = 0x02000000,
|
||||||
|
AYUV = 0x04000000,
|
||||||
|
ABGR = 0x10000000,
|
||||||
|
ABGR10 = 0x20000000,
|
||||||
|
U8 = 0x40000000,
|
||||||
|
NV16 = 0x40000001,
|
||||||
|
P210 = 0x40000002,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NvencLevel {
|
||||||
|
AutoSelect = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Translate level macro
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum H264Level {
|
||||||
|
H264_1 = 10,
|
||||||
|
H264_1b = 9,
|
||||||
|
H264_11 = 11,
|
||||||
|
H264_12 = 12,
|
||||||
|
H264_13 = 13,
|
||||||
|
H264_2 = 20,
|
||||||
|
H264_21 = 21,
|
||||||
|
H264_22 = 22,
|
||||||
|
H264_3 = 30,
|
||||||
|
H264_31 = 31,
|
||||||
|
H264_32 = 32,
|
||||||
|
H264_4 = 40,
|
||||||
|
H264_41 = 41,
|
||||||
|
H264_42 = 42,
|
||||||
|
H264_5 = 50,
|
||||||
|
H264_51 = 51,
|
||||||
|
H264_52 = 52,
|
||||||
|
H264_60 = 60,
|
||||||
|
H264_61 = 61,
|
||||||
|
H264_62 = 62,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum HEVCLevel {
|
||||||
|
HEVC1 = 30,
|
||||||
|
HEVC2 = 60,
|
||||||
|
HEVC21 = 63,
|
||||||
|
HEVC3 = 90,
|
||||||
|
HEVC31 = 93,
|
||||||
|
HEVC4 = 120,
|
||||||
|
HEVC41 = 123,
|
||||||
|
HEVC5 = 150,
|
||||||
|
HEVC51 = 153,
|
||||||
|
HEVC52 = 156,
|
||||||
|
HEVC6 = 180,
|
||||||
|
HEVC61 = 183,
|
||||||
|
HEVC62 = 186,
|
||||||
|
HEVCMain = 0,
|
||||||
|
HEVCHigh = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum AV1Level {
|
||||||
|
// Also AV1_0
|
||||||
|
AV1_2 = 0,
|
||||||
|
// Also AV1_1
|
||||||
|
AV1_21 = 1,
|
||||||
|
AV1_22 = 2,
|
||||||
|
AV1_23 = 3,
|
||||||
|
AV1_3 = 4,
|
||||||
|
AV1_31 = 5,
|
||||||
|
AV1_32 = 6,
|
||||||
|
AV1_33 = 7,
|
||||||
|
AV1_4 = 8,
|
||||||
|
AV1_41 = 9,
|
||||||
|
AV1_42 = 10,
|
||||||
|
AV1_43 = 11,
|
||||||
|
AV1_5 = 12,
|
||||||
|
AV1_51 = 13,
|
||||||
|
AV1_52 = 14,
|
||||||
|
AV1_53 = 15,
|
||||||
|
AV1_6 = 16,
|
||||||
|
AV1_61 = 17,
|
||||||
|
AV1_62 = 18,
|
||||||
|
AV1_63 = 19,
|
||||||
|
AV1_7 = 20,
|
||||||
|
AV1_71 = 21,
|
||||||
|
AV1_72 = 22,
|
||||||
|
AV1_73 = 23,
|
||||||
|
AV1AutoSelect = 24,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencPicFlags {
|
||||||
|
ForceIntra = 0x1,
|
||||||
|
ForceIDR = 0x2,
|
||||||
|
OutputSpspps = 0x4,
|
||||||
|
Eos = 0x8,
|
||||||
|
DisableEncStateAdvance = 0x10,
|
||||||
|
OuputReconFrame = 0x20,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencMemoryHeap {
|
||||||
|
AutoSelect = 0,
|
||||||
|
VID = 1,
|
||||||
|
SystemCached = 2,
|
||||||
|
SystemUncached = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencBFrameRefMode {
|
||||||
|
Disabled = 0x0,
|
||||||
|
Each = 0x1,
|
||||||
|
Middle = 0x2,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencH264EntropyCodingMode {
|
||||||
|
AutoSelect = 0x0,
|
||||||
|
Cabac = 0x1,
|
||||||
|
Cavlc = 0x2,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencH264BDirectMode {
|
||||||
|
Autoselect = 0x0,
|
||||||
|
Disable = 0x1,
|
||||||
|
Temporal = 0x2,
|
||||||
|
Spatial = 0x3,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencH264FMOMode {
|
||||||
|
AutoSelect = 0x0,
|
||||||
|
Enable = 0x1,
|
||||||
|
Disable = 0x2,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencH264AdaptiveTransformMode {
|
||||||
|
AutoSelect = 0x0,
|
||||||
|
Disable = 0x1,
|
||||||
|
Enable = 0x2,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencStereoPackingMode {
|
||||||
|
None = 0x0,
|
||||||
|
Checkerboard = 0x1,
|
||||||
|
ColInterleave = 0x2,
|
||||||
|
RowInterleave = 0x3,
|
||||||
|
SideBySide = 0x4,
|
||||||
|
TopBottm = 0x5,
|
||||||
|
FrameSeq = 0x6,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencInputResourceType {
|
||||||
|
DirectX = 0x0,
|
||||||
|
CudaDeivcePtr = 0x1,
|
||||||
|
CudaArray = 0x2,
|
||||||
|
OpenGLTex = 0x3,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencBufferUsage {
|
||||||
|
Image = 0x0,
|
||||||
|
MotionVector = 0x1,
|
||||||
|
BitStream = 0x2,
|
||||||
|
OutputRecon = 0x4,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum NVencDeviceType {
|
||||||
|
DirectX = 0x0,
|
||||||
|
Cuda = 0x1,
|
||||||
|
OpenGL = 0x2,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencNumRefFrames {
|
||||||
|
AutoSelect = 0x0,
|
||||||
|
Frames1 = 0x1,
|
||||||
|
Frames2 = 0x2,
|
||||||
|
Frames3 = 0x3,
|
||||||
|
Frames4 = 0x4,
|
||||||
|
Frames5 = 0x5,
|
||||||
|
Frames6 = 0x6,
|
||||||
|
Frames7 = 0x7,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencTemporalFilterLevel {
|
||||||
|
Level0 = 0,
|
||||||
|
Level4 = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencCaps {
|
||||||
|
NumMaxBFrames,
|
||||||
|
SupportedRateControlModes,
|
||||||
|
FieldEncoding,
|
||||||
|
MonoChrome,
|
||||||
|
FMO,
|
||||||
|
QPELMV,
|
||||||
|
BDirectMode,
|
||||||
|
CABAC,
|
||||||
|
AdaptiveTransform,
|
||||||
|
StereoMVC,
|
||||||
|
MaxTemporalLayers,
|
||||||
|
HierarchicalBFrames,
|
||||||
|
LevelMax,
|
||||||
|
LevelMin,
|
||||||
|
SeperateColorPlane,
|
||||||
|
WidthMax,
|
||||||
|
HeightMax,
|
||||||
|
TemporalSVC,
|
||||||
|
DynResChnage,
|
||||||
|
DynBitrateChange,
|
||||||
|
DynForceConstQP,
|
||||||
|
DynRcModeChange,
|
||||||
|
SubframeReadBack,
|
||||||
|
ConstrainedEncoding,
|
||||||
|
IntraRefresh,
|
||||||
|
CustomVBVBufSize,
|
||||||
|
DynamicSliceMode,
|
||||||
|
Invalidation,
|
||||||
|
PreProcSupport,
|
||||||
|
AsyncEncodeSupport,
|
||||||
|
MbNumMax,
|
||||||
|
MbPerSecMax,
|
||||||
|
YUV444Encode,
|
||||||
|
LoselessEncode,
|
||||||
|
SupportSao,
|
||||||
|
MeOnlyMode,
|
||||||
|
LookAhead,
|
||||||
|
TemporalAQ,
|
||||||
|
MaxLtrFrames,
|
||||||
|
SupportWeightedPrediction,
|
||||||
|
DynamicQueryEncoderCapacity,
|
||||||
|
BFrameRefMode,
|
||||||
|
EmphasisLevelMap,
|
||||||
|
WidthMin,
|
||||||
|
HeightMin,
|
||||||
|
MultipleRefFrames,
|
||||||
|
AlphaLayerEncoding,
|
||||||
|
NumEncoderEngines,
|
||||||
|
SingleSliceIntraRefresh,
|
||||||
|
DisableEncStateAdvance,
|
||||||
|
OutputReconSurface,
|
||||||
|
OutputBlockStats,
|
||||||
|
SupportTemporalFilter,
|
||||||
|
LookAheadLevel,
|
||||||
|
UndirectionalB,
|
||||||
|
MVHEVCEncode,
|
||||||
|
SupportYUV422Encode,
|
||||||
|
ExposedCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencHEVCCuSize {
|
||||||
|
AutoSelect = 0,
|
||||||
|
Size8x8 = 1,
|
||||||
|
Size16x16 = 2,
|
||||||
|
Size32x32 = 3,
|
||||||
|
Size64x64 = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencAV1PartSize {
|
||||||
|
AutoSelect = 0,
|
||||||
|
Size4x4 = 1,
|
||||||
|
Size8x8 = 2,
|
||||||
|
Size16x16 = 3,
|
||||||
|
Size32x32 = 4,
|
||||||
|
Size64x64 = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencVUIVideoFormat {
|
||||||
|
Component = 0,
|
||||||
|
Pal = 1,
|
||||||
|
NTSC = 2,
|
||||||
|
SeCam = 3,
|
||||||
|
Mac = 4,
|
||||||
|
Unspecified = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencVUIColorPrimaries {
|
||||||
|
Undefined = 0,
|
||||||
|
BT709 = 1,
|
||||||
|
Unspecified = 2,
|
||||||
|
Reserved = 3,
|
||||||
|
BT470M = 4,
|
||||||
|
BT470BG = 5,
|
||||||
|
SMPTE170M = 6,
|
||||||
|
SMPTE240M = 7,
|
||||||
|
FIlm = 8,
|
||||||
|
BT2020 = 9,
|
||||||
|
SMPTE428 = 10,
|
||||||
|
SMPTE431 = 11,
|
||||||
|
SMPTE432 = 12,
|
||||||
|
JEDECP22 = 22,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencVUITransCharacteristics {
|
||||||
|
Undefined = 0,
|
||||||
|
BT709 = 1,
|
||||||
|
Unspecified = 2,
|
||||||
|
Reserved = 3,
|
||||||
|
BT470M = 4,
|
||||||
|
BT470BG = 5,
|
||||||
|
SMPTE170M = 6,
|
||||||
|
SMPTE240M = 7,
|
||||||
|
Linear = 8,
|
||||||
|
Log = 9,
|
||||||
|
LogSqrt = 10,
|
||||||
|
IEC61966_2_4 = 11,
|
||||||
|
SRGB = 13,
|
||||||
|
BT2020_10 = 14,
|
||||||
|
BT2020_12 = 15,
|
||||||
|
SMPTE2084 = 16,
|
||||||
|
SMPTE428 = 17,
|
||||||
|
AribStdB67 = 18,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencVUIMatrixCoeffs {
|
||||||
|
RGB = 0,
|
||||||
|
BT709 = 1,
|
||||||
|
Unspecified = 2,
|
||||||
|
Reserved = 3,
|
||||||
|
FCC = 4,
|
||||||
|
BT470BG = 5,
|
||||||
|
SMPTE170M,
|
||||||
|
SMPTE240M,
|
||||||
|
YCGCO = 8,
|
||||||
|
BT2020NCL = 9,
|
||||||
|
BT2020CL = 10,
|
||||||
|
SMPTE2085 = 11,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencLookAheadLevel {
|
||||||
|
Level0 = 0,
|
||||||
|
Level1 = 1,
|
||||||
|
Level2 = 2,
|
||||||
|
Level3 = 3,
|
||||||
|
AutoSelect = 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencBitDepth {
|
||||||
|
Invalid = 0,
|
||||||
|
Depth8 = 8,
|
||||||
|
Depth10 = 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencTuningInfo {
|
||||||
|
Undefined = 0,
|
||||||
|
HighQuality = 1,
|
||||||
|
LowLatency = 2,
|
||||||
|
UltraLowLatency = 3,
|
||||||
|
Lossless = 4,
|
||||||
|
UltraHighQuality = 5,
|
||||||
|
Count,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencSplitEncodeMode {
|
||||||
|
Audo = 0,
|
||||||
|
AutoForced = 1,
|
||||||
|
TwoForced = 2,
|
||||||
|
ThreeForced = 3,
|
||||||
|
FourForced = 4,
|
||||||
|
Disable = 15,
|
||||||
|
}
|
||||||
214
vendor/nvenc/src/sys/function_table.rs
vendored
Normal file
214
vendor/nvenc/src/sys/function_table.rs
vendored
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
use std::{ffi::c_char, mem::MaybeUninit, os::raw::c_void};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
stdcall,
|
||||||
|
sys::{
|
||||||
|
enums::{NVencBufferFormat, NVencTuningInfo},
|
||||||
|
result::NVencResult,
|
||||||
|
structs::{
|
||||||
|
Guid, NVencCapsParam, NVencCreateBitstreamBuffer, NVencCreateInputBuffer,
|
||||||
|
NVencInitializeParams, NVencLockBitStream, NVencLockInputBuffer, NVencMapInputResource,
|
||||||
|
NVencOpenEncodeSessionExParams, NVencPicParams, NVencPresetConfig,
|
||||||
|
NVencRegisterResource,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct NVencFunctionList {
|
||||||
|
pub version: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
#[deprecated]
|
||||||
|
nvenc_open_encode_session: NvencOpenEncodeSession,
|
||||||
|
pub nvenc_get_encoder_guid_count: NvencGetEncodeGuidCount,
|
||||||
|
nvenc_get_encoder_profile_guid_count: NvencGetEncodeProfileGuidCount,
|
||||||
|
nvenc_get_encoder_profile_guids: NvencGetEncodeProfileGuids,
|
||||||
|
pub nvenc_get_encoder_guids: NvencGetEncodeGuids,
|
||||||
|
nvenc_get_input_format_count: NvencGetInputFormatCount,
|
||||||
|
nvenc_get_input_formats: NvencGetInputFormats,
|
||||||
|
nvenc_get_encode_caps: NvencGetEncodeCaps,
|
||||||
|
pub nvenc_get_encode_preset_count: NvencGetEncodePresetCount,
|
||||||
|
pub nvenc_get_encode_preset_guids: NvencGetEncodePresetGuids,
|
||||||
|
nvenc_get_encode_preset_config: NvencGetEncodePresetConfig,
|
||||||
|
pub nvenc_initialize_encoder: NvencInitializeEncoder,
|
||||||
|
pub nvenc_create_input_buffer: NvencCreateInputBuffer,
|
||||||
|
pub nvenc_destroy_input_buffer: NvencDestroyInputBuffer,
|
||||||
|
pub nvenc_create_bit_stream_buffer: NvencCreateBitStreamBuffer,
|
||||||
|
pub nvenc_destory_bit_stream_buffer: NvencDestroyBitStreamBuffer,
|
||||||
|
pub nvenc_encode_picture: NvencEncodePicture,
|
||||||
|
pub nvenc_lock_bit_stream: NvencLockBitStream,
|
||||||
|
pub nvenc_unlock_bit_stream: NvencUnlockBitStream,
|
||||||
|
pub nvenc_lock_input_buffer: NvencLockInputBuffer,
|
||||||
|
pub nvenc_unlock_input_buffer: NvencUnlockInputBuffer,
|
||||||
|
nvenc_get_encode_stats: NvencGetEncodeStats,
|
||||||
|
nvenc_get_sequence_params: NvencGetSequenceParams,
|
||||||
|
nvenc_register_async_event: NvencRegisterAsyncEvent,
|
||||||
|
nvenc_unregister_async_event: NvencUnregisterAsyncEvent,
|
||||||
|
pub nvenc_map_input_resource: NvencMapInputResource,
|
||||||
|
pub nvenc_unmap_input_resource: NvencUnmapInputResource,
|
||||||
|
pub nvenc_destroy_encoder: NvencDestroyEncoder,
|
||||||
|
nvenc_invalidate_ref_frames: NvencInvalidateRefFrames,
|
||||||
|
pub nvenc_open_encode_session_ex: NvencOpenEncodeSessionEx,
|
||||||
|
pub nvenc_register_resource: NvencRegisterResource,
|
||||||
|
pub nvenc_unregister_resource: NvencUnRegisterResource,
|
||||||
|
nvenc_reconfigure_encoder: NvencReconfigureEncoder,
|
||||||
|
rsvd1: *mut c_void,
|
||||||
|
nvenc_create_mv_buffer: NvencCreateMVBuffer,
|
||||||
|
nvenc_destory_mv_buffer: NvencDestoryMVBuffer,
|
||||||
|
nvenc_run_motion_estimation_only: NvencRunMotionEstimationOnly,
|
||||||
|
pub nvenc_get_last_error: NvencGetLastError,
|
||||||
|
nvenc_set_io_cuda_streams: NvencSetIOCudaStreams,
|
||||||
|
pub nvenc_get_encode_preset_config_ex: NvencGetEncodePresetConfigEx,
|
||||||
|
nvenc_get_sequence_params_ex: NvencGetSequenceParamsEx,
|
||||||
|
nvenc_store_encoder_state: NvencStoreEncoderState,
|
||||||
|
nvenc_look_ahead_picture: NvencLookAheadPicture,
|
||||||
|
rsvd2: [*mut c_void; 275],
|
||||||
|
}
|
||||||
|
|
||||||
|
type NvencOpenEncodeSession =
|
||||||
|
stdcall!(fn(device: *mut c_void, device_type: i32, encoder: *mut *mut c_void) -> NVencResult);
|
||||||
|
type NvencGetEncodeGuidCount =
|
||||||
|
stdcall!(fn(encoder: *mut c_void, encoder_guid_count: *mut u32) -> NVencResult);
|
||||||
|
type NvencGetEncodeGuids = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
guids: *mut Guid,
|
||||||
|
guid_array_size: u32,
|
||||||
|
guid_count: *mut u32,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencGetEncodeProfileGuidCount = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
encode_guid: Guid,
|
||||||
|
encoder_profile_guid_count: *mut u32,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencGetEncodeProfileGuids = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
profile_guids: *mut Guid,
|
||||||
|
guid_array_size: u32,
|
||||||
|
guid_count: *mut u32,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencGetInputFormatCount = stdcall!(
|
||||||
|
fn(encoder: *mut c_void, encoder_guid: Guid, input_fmt_count: *mut u32) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencGetInputFormats = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
input_formats: *mut NVencBufferFormat,
|
||||||
|
input_format_array_size: u32,
|
||||||
|
input_format_count: *mut u32,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencGetEncodeCaps = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
encoder_guid: Guid,
|
||||||
|
caps_param: *mut NVencCapsParam,
|
||||||
|
caps_val: *mut i32,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencGetEncodePresetCount = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
encoder_guid: Guid,
|
||||||
|
encoder_preset_guid_count: *mut u32,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencGetEncodePresetGuids = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
encode_guid: Guid,
|
||||||
|
preset_guids: *mut Guid,
|
||||||
|
guid_array_size: u32,
|
||||||
|
encoder_preset_guid_count: *mut u32,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencGetEncodePresetConfig = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
encode_guid: Guid,
|
||||||
|
preset_guid: Guid,
|
||||||
|
preset_config: *mut NVencPresetConfig,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencGetEncodePresetConfigEx = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
encode_guid: Guid,
|
||||||
|
preset_guid: Guid,
|
||||||
|
tuning_info: NVencTuningInfo,
|
||||||
|
preset_config: *mut MaybeUninit<NVencPresetConfig>,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencInitializeEncoder = stdcall!(
|
||||||
|
fn(encoder: *mut c_void, create_encode_params: *mut NVencInitializeParams) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencCreateInputBuffer = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
create_input_buffer_params: *mut NVencCreateInputBuffer,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencDestroyInputBuffer =
|
||||||
|
stdcall!(fn(encoder: *mut c_void, input_buffer: *mut c_void) -> NVencResult);
|
||||||
|
type NvencCreateBitStreamBuffer = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
create_bit_stream_buffer_params: *mut NVencCreateBitstreamBuffer,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencDestroyBitStreamBuffer =
|
||||||
|
stdcall!(fn(encoder: *mut c_void, bitstream_buffer: *mut c_void) -> NVencResult);
|
||||||
|
type NvencEncodePicture = stdcall!(
|
||||||
|
fn(encoder: *mut c_void, encode_pic_params: *mut MaybeUninit<NVencPicParams>) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencLockBitStream = stdcall!(
|
||||||
|
fn(
|
||||||
|
encoder: *mut c_void,
|
||||||
|
lock_bitstream_buffer_params: *mut MaybeUninit<NVencLockBitStream>,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencUnlockBitStream =
|
||||||
|
stdcall!(fn(encoder: *mut c_void, bitstream_buffer: *mut c_void) -> NVencResult);
|
||||||
|
type NvencLockInputBuffer = stdcall!(
|
||||||
|
fn(encoder: *mut c_void, lock_input_buffer_params: *mut NVencLockInputBuffer) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencUnlockInputBuffer =
|
||||||
|
stdcall!(fn(encoder: *mut c_void, input_buffer: *mut c_void) -> NVencResult);
|
||||||
|
type NvencGetEncodeStats = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencGetSequenceParams = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencRegisterAsyncEvent = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencUnregisterAsyncEvent = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencMapInputResource = stdcall!(
|
||||||
|
fn(encoder: *mut c_void, map_input_resource_params: *mut NVencMapInputResource) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencUnmapInputResource =
|
||||||
|
stdcall!(fn(encoder: *mut c_void, mapped_input_buffer: *mut c_void) -> NVencResult);
|
||||||
|
type NvencDestroyEncoder = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencInvalidateRefFrames = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencOpenEncodeSessionEx = stdcall!(
|
||||||
|
fn(
|
||||||
|
open_session_ex_params: *mut NVencOpenEncodeSessionExParams,
|
||||||
|
encoder: *mut *mut c_void,
|
||||||
|
) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencRegisterResource = stdcall!(
|
||||||
|
fn(encoder: *mut c_void, register_res_params: *mut NVencRegisterResource) -> NVencResult
|
||||||
|
);
|
||||||
|
type NvencUnRegisterResource =
|
||||||
|
stdcall!(fn(encoder: *mut c_void, registered_res: *mut c_void) -> NVencResult);
|
||||||
|
type NvencReconfigureEncoder = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
|
||||||
|
type NvencCreateMVBuffer = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencDestoryMVBuffer = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencRunMotionEstimationOnly = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencGetLastError = stdcall!(fn(encoder: *mut c_void) -> *const c_char);
|
||||||
|
type NvencSetIOCudaStreams = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencGetSequenceParamsEx = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencStoreEncoderState = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
|
type NvencLookAheadPicture = stdcall!(fn(encoder: *mut c_void) -> NVencResult);
|
||||||
146
vendor/nvenc/src/sys/guids.rs
vendored
Normal file
146
vendor/nvenc/src/sys/guids.rs
vendored
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
use crate::sys::structs::Guid;
|
||||||
|
|
||||||
|
pub const NV_ENC_CODEC_H264_GUID: Guid = Guid::from_values(
|
||||||
|
0x6bc82762,
|
||||||
|
0x4e63,
|
||||||
|
0x4ca4,
|
||||||
|
[0xaa, 0x85, 0x1e, 0x50, 0xf3, 0x21, 0xf6, 0xbf],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_CODEC_HEVC_GUID: Guid = Guid::from_values(
|
||||||
|
0x790cdc88,
|
||||||
|
0x4522,
|
||||||
|
0x4d7b,
|
||||||
|
[0x94, 0x25, 0xbd, 0xa9, 0x97, 0x5f, 0x76, 0x3],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_CODEC_AV1_GUID: Guid = Guid::from_values(
|
||||||
|
0x0a352289,
|
||||||
|
0x0aa7,
|
||||||
|
0x4759,
|
||||||
|
[0x86, 0x2d, 0x5d, 0x15, 0xcd, 0x16, 0xd2, 0x54],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_CODEC_PROFILE_AUTOSELECT_GUID: Guid = Guid::from_values(
|
||||||
|
0xbfd6f8e7,
|
||||||
|
0x233c,
|
||||||
|
0x4341,
|
||||||
|
[0x8b, 0x3e, 0x48, 0x18, 0x52, 0x38, 0x3, 0xf4],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_H264_PROFILE_BASELINE_GUID: Guid = Guid::from_values(
|
||||||
|
0x727bcaa,
|
||||||
|
0x78c4,
|
||||||
|
0x4c83,
|
||||||
|
[0x8c, 0x2f, 0xef, 0x3d, 0xff, 0x26, 0x7c, 0x6a],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_H264_PROFILE_MAIN_GUID: Guid = Guid::from_values(
|
||||||
|
0x60b5c1d4,
|
||||||
|
0x67fe,
|
||||||
|
0x4790,
|
||||||
|
[0x94, 0xd5, 0xc4, 0x72, 0x6d, 0x7b, 0x6e, 0x6d],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_H264_PROFILE_HIGH_GUID: Guid = Guid::from_values(
|
||||||
|
0xe7cbc309,
|
||||||
|
0x4f7a,
|
||||||
|
0x4b89,
|
||||||
|
[0xaf, 0x2a, 0xd5, 0x37, 0xc9, 0x2b, 0xe3, 0x10],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_H264_PROFILE_HIGH_10_GUID: Guid = Guid::from_values(
|
||||||
|
0x8f0c337e,
|
||||||
|
0x186c,
|
||||||
|
0x48e9,
|
||||||
|
[0xa6, 0x9d, 0x7a, 0x83, 0x34, 0x08, 0x97, 0x5],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_H264_PROFILE_HIGH_422_GUID: Guid = Guid::from_values(
|
||||||
|
0xff3242e9,
|
||||||
|
0x613c,
|
||||||
|
0x4295,
|
||||||
|
[0xa1, 0xe8, 0x2a, 0x7f, 0xe9, 0x4d, 0x81, 0x33],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_H264_PROFILE_HIGH_444_GUID: Guid = Guid::from_values(
|
||||||
|
0x7ac663cb,
|
||||||
|
0xa598,
|
||||||
|
0x4960,
|
||||||
|
[0xb8, 0x44, 0x33, 0x9b, 0x26, 0x1a, 0x7d, 0x52],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_H264_PROFILE_STEREO_GUID: Guid = Guid::from_values(
|
||||||
|
0x40847bf5,
|
||||||
|
0x33f7,
|
||||||
|
0x4601,
|
||||||
|
[0x90, 0x84, 0xe8, 0xfe, 0x3c, 0x1d, 0xb8, 0xb7],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_H264_PROFILE_PROGRESSIVE_HIGH_GUID: Guid = Guid::from_values(
|
||||||
|
0xb405afac,
|
||||||
|
0xf32b,
|
||||||
|
0x417b,
|
||||||
|
[0x89, 0xc4, 0x9a, 0xbe, 0xed, 0x3e, 0x59, 0x78],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_H264_PROFILE_CONSTRAINED_HIGH_GUID: Guid = Guid::from_values(
|
||||||
|
0xaec1bd87,
|
||||||
|
0xe85b,
|
||||||
|
0x48f2,
|
||||||
|
[0x84, 0xc3, 0x98, 0xbc, 0xa6, 0x28, 0x50, 0x72],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_HEVC_PROFILE_MAIN_GUID: Guid = Guid::from_values(
|
||||||
|
0xb514c39a,
|
||||||
|
0xb55b,
|
||||||
|
0x40fa,
|
||||||
|
[0x87, 0x8f, 0xf1, 0x25, 0x3b, 0x4d, 0xfd, 0xec],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_HEVC_PROFILE_MAIN10_GUID: Guid = Guid::from_values(
|
||||||
|
0xfa4d2b6c,
|
||||||
|
0x3a5b,
|
||||||
|
0x411a,
|
||||||
|
[0x80, 0x18, 0x0a, 0x3f, 0x5e, 0x3c, 0x9b, 0xe5],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_HEVC_PROFILE_FREXT_GUID: Guid = Guid::from_values(
|
||||||
|
0x51ec32b5,
|
||||||
|
0x1b4c,
|
||||||
|
0x453c,
|
||||||
|
[0x9c, 0xbd, 0xb6, 0x16, 0xbd, 0x62, 0x13, 0x41],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_AV1_PROFILE_MAIN_GUID: Guid = Guid::from_values(
|
||||||
|
0x5f2a39f5,
|
||||||
|
0xf14e,
|
||||||
|
0x4f95,
|
||||||
|
[0x9a, 0x9e, 0xb7, 0x6d, 0x56, 0x8f, 0xcf, 0x97],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_PRESET_P1_GUID: Guid = Guid::from_values(
|
||||||
|
0xfc0a8d3e,
|
||||||
|
0x45f8,
|
||||||
|
0x4cf8,
|
||||||
|
[0x80, 0xc7, 0x29, 0x88, 0x71, 0x59, 0xe, 0xbf],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_PRESET_P2_GUID: Guid = Guid::from_values(
|
||||||
|
0xf581cfb8,
|
||||||
|
0x88d6,
|
||||||
|
0x4381,
|
||||||
|
[0x93, 0xf0, 0xdf, 0x13, 0xf9, 0xc2, 0x7d, 0xab],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_PRESET_P3_GUID: Guid = Guid::from_values(
|
||||||
|
0x36850110,
|
||||||
|
0x3a07,
|
||||||
|
0x441f,
|
||||||
|
[0x94, 0xd5, 0x36, 0x70, 0x63, 0x1f, 0x91, 0xf6],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_PRESET_P4_GUID: Guid = Guid::from_values(
|
||||||
|
0x90a7b826,
|
||||||
|
0xdf06,
|
||||||
|
0x4862,
|
||||||
|
[0xb9, 0xd2, 0xcd, 0x6d, 0x73, 0xa0, 0x86, 0x81],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_PRESET_P5_GUID: Guid = Guid::from_values(
|
||||||
|
0x21c6e6b4,
|
||||||
|
0x297a,
|
||||||
|
0x4cba,
|
||||||
|
[0x99, 0x8f, 0xb6, 0xcb, 0xde, 0x72, 0xad, 0xe3],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_PRESET_P6_GUID: Guid = Guid::from_values(
|
||||||
|
0x8e75c279,
|
||||||
|
0x6299,
|
||||||
|
0x4ab6,
|
||||||
|
[0x83, 0x2, 0xb, 0x21, 0x5a, 0x33, 0x5c, 0xf5],
|
||||||
|
);
|
||||||
|
pub const NV_ENC_PRESET_P7_GUID: Guid = Guid::from_values(
|
||||||
|
0x84848c12,
|
||||||
|
0x6f71,
|
||||||
|
0x4c13,
|
||||||
|
[0x93, 0x1b, 0x53, 0xe2, 0x83, 0xf5, 0x79, 0x74],
|
||||||
|
);
|
||||||
6
vendor/nvenc/src/sys/mod.rs
vendored
Normal file
6
vendor/nvenc/src/sys/mod.rs
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
pub mod enums;
|
||||||
|
pub mod function_table;
|
||||||
|
pub mod guids;
|
||||||
|
pub mod result;
|
||||||
|
pub mod structs;
|
||||||
|
pub mod version;
|
||||||
79
vendor/nvenc/src/sys/result.rs
vendored
Normal file
79
vendor/nvenc/src/sys/result.rs
vendored
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencResult {
|
||||||
|
Success,
|
||||||
|
NoDevice,
|
||||||
|
UnsupportedDevice,
|
||||||
|
InvalidEncoderDevice,
|
||||||
|
InvalidDevice,
|
||||||
|
DeviceNotExist,
|
||||||
|
InvalidPtr,
|
||||||
|
InvalidParam,
|
||||||
|
InvalidEvent,
|
||||||
|
InvalidCall,
|
||||||
|
OutOfMemory,
|
||||||
|
NotInitialized,
|
||||||
|
UnsupportedParam,
|
||||||
|
LockBusy,
|
||||||
|
NotEnoughBuffer,
|
||||||
|
InvalidVersion,
|
||||||
|
MapFailed,
|
||||||
|
NeedMoreInput,
|
||||||
|
EncoderBusy,
|
||||||
|
EventNotRegisterd,
|
||||||
|
Generic,
|
||||||
|
IncompatibleClientKey,
|
||||||
|
Unimplemented,
|
||||||
|
ResourceRegisterFailed,
|
||||||
|
ResourceNotRegistered,
|
||||||
|
ResourceNotMapped,
|
||||||
|
NeedMoreOutput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NVencResult {
|
||||||
|
pub fn into_error(self) -> Result<(), NVencError> {
|
||||||
|
NVencError::from_result(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NVencError {
|
||||||
|
pub fn from_result(value: NVencResult) -> Result<(), Self> {
|
||||||
|
if value == NVencResult::Success {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
// Safety: They are one case different, so they always align
|
||||||
|
unsafe { Err(std::mem::transmute::<NVencResult, NVencError>(value)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
#[repr(C)]
|
||||||
|
pub enum NVencError {
|
||||||
|
NoDevice = NVencResult::NoDevice as isize,
|
||||||
|
UnsupportedDevice,
|
||||||
|
InvalidEncoderDevice,
|
||||||
|
InvalidDevice,
|
||||||
|
DeviceNotExist,
|
||||||
|
InvalidPtr,
|
||||||
|
InvalidEvent,
|
||||||
|
InvalidParam,
|
||||||
|
InvalidCall,
|
||||||
|
OutOfMemory,
|
||||||
|
NotInitialized,
|
||||||
|
UnsupportedParam,
|
||||||
|
LockBusy,
|
||||||
|
NotEnoughBuffer,
|
||||||
|
InvalidVersion,
|
||||||
|
MapFailed,
|
||||||
|
NeedMoreInput,
|
||||||
|
EncoderBusy,
|
||||||
|
EventNotRegisterd,
|
||||||
|
Generic,
|
||||||
|
IncompatibleClientKey,
|
||||||
|
Unimplemented,
|
||||||
|
ResourceRegisterFailed,
|
||||||
|
ResourceNotRegistered,
|
||||||
|
ResourceNotMapped,
|
||||||
|
NeedMoreOutput,
|
||||||
|
}
|
||||||
944
vendor/nvenc/src/sys/structs.rs
vendored
Normal file
944
vendor/nvenc/src/sys/structs.rs
vendored
Normal file
@ -0,0 +1,944 @@
|
|||||||
|
use std::{ffi::c_void, mem::ManuallyDrop};
|
||||||
|
|
||||||
|
use crate::sys::{
|
||||||
|
enums::{
|
||||||
|
NVencAV1PartSize, NVencBFrameRefMode, NVencBitDepth, NVencBufferFormat, NVencBufferUsage,
|
||||||
|
NVencCaps, NVencDeviceType, NVencDisplayPicStruct, NVencH264AdaptiveTransformMode,
|
||||||
|
NVencH264BDirectMode, NVencH264EntropyCodingMode, NVencH264FMOMode, NVencHEVCCuSize,
|
||||||
|
NVencInputResourceType, NVencLookAheadLevel, NVencMVPrecision, NVencMemoryHeap,
|
||||||
|
NVencMultiPass, NVencNumRefFrames, NVencOutputStatsLevel, NVencParamsFrameFieldMode,
|
||||||
|
NVencParamsRcMode, NVencPicStruct, NVencPicType, NVencQPMapMode, NVencStateRestoreType,
|
||||||
|
NVencStereoPackingMode, NVencTemporalFilterLevel, NVencTuningInfo, NVencVUIColorPrimaries,
|
||||||
|
NVencVUIMatrixCoeffs, NVencVUITransCharacteristics, NVencVUIVideoFormat,
|
||||||
|
},
|
||||||
|
version::struct_version,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default, Clone, PartialEq, Eq)]
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct Guid {
|
||||||
|
pub data1: u32,
|
||||||
|
pub data2: u16,
|
||||||
|
pub data3: u16,
|
||||||
|
pub data4: [u8; 8],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Guid {
|
||||||
|
pub const fn from_values(data1: u32, data2: u16, data3: u16, data4: [u8; 8]) -> Self {
|
||||||
|
Self {
|
||||||
|
data1,
|
||||||
|
data2,
|
||||||
|
data3,
|
||||||
|
data4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn from_u128(uuid: u128) -> Self {
|
||||||
|
Self {
|
||||||
|
data1: (uuid >> 96) as u32,
|
||||||
|
data2: ((uuid >> 80) & 0xffff) as u16,
|
||||||
|
data3: ((uuid >> 64) & 0xffff) as u16,
|
||||||
|
data4: (uuid as u64).to_be_bytes(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencCapsParam {
|
||||||
|
version: u32,
|
||||||
|
caps_to_query: NVencCaps,
|
||||||
|
rsvd: [u32; 62],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_CAPS_PARAM_VERSION: u32 = struct_version(1);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencRestoreEncoderStateParams {
|
||||||
|
version: u32,
|
||||||
|
buffer: u32,
|
||||||
|
state: NVencStateRestoreType,
|
||||||
|
rsvd: u32,
|
||||||
|
output_bit_stream: *mut c_void,
|
||||||
|
completion_event: *mut c_void,
|
||||||
|
rsvd1: [u32; 64],
|
||||||
|
rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_RESTORE_STATE_PARAMS_VER: u32 = struct_version(2);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencOutputStatsBlock {
|
||||||
|
version: u32,
|
||||||
|
qp: u8,
|
||||||
|
rsvd: [u8; 3],
|
||||||
|
bit_count: u32,
|
||||||
|
satd_cost: u32,
|
||||||
|
rsvd1: [u32; 12],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_OUTPUT_STATS_BLOCK_VER: u32 = struct_version(1);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencOutputStatsRow {
|
||||||
|
version: u32,
|
||||||
|
qp: u8,
|
||||||
|
rsvd: [u8; 3],
|
||||||
|
bit_count: u32,
|
||||||
|
satd_cost: u32,
|
||||||
|
rsvd1: [u32; 12],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_OUTPUT_STATS_ROW_VER: u32 = struct_version(1);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencEncodeOutParams {
|
||||||
|
version: u32,
|
||||||
|
bitstream_size_in_bytes: u32,
|
||||||
|
rsvd: [u32; 62],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_ENCODE_OUT_PARAMS_VER: u32 = struct_version(1);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencLookAheadPicParams {
|
||||||
|
version: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
input_buffer: *mut c_void,
|
||||||
|
picture_type: NVencPicType,
|
||||||
|
rsvd1: [u32; 63],
|
||||||
|
rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_LOOKAHEAD_PIC_PARAMS_VER: u32 = struct_version(2);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencCreateInputBuffer {
|
||||||
|
pub version: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub memory_heap: NVencMemoryHeap,
|
||||||
|
pub buffer_fmt: NVencBufferFormat,
|
||||||
|
pub rsvd: u32,
|
||||||
|
pub input_buffer: *mut c_void,
|
||||||
|
pub p_sys_mem_buffer: *mut c_void,
|
||||||
|
pub rsvd1: [u32; 58],
|
||||||
|
pub rsvd2: [*mut c_void; 63],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_CREATE_INPUT_BUFFER_VER: u32 = struct_version(2);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencCreateBitstreamBuffer {
|
||||||
|
pub version: u32,
|
||||||
|
#[deprecated]
|
||||||
|
size: u32,
|
||||||
|
#[deprecated]
|
||||||
|
memory_heap: NVencMemoryHeap,
|
||||||
|
rsvd: u32,
|
||||||
|
pub bitstream_buffer: *mut c_void,
|
||||||
|
bitstream_buffer_ptr: *mut c_void,
|
||||||
|
rsvd1: [u32; 58],
|
||||||
|
rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_CREATE_BITSTREAM_BUFFER_VER: u32 = struct_version(1);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencMVector {
|
||||||
|
mvx: i16,
|
||||||
|
mvy: i16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencH264MVData {
|
||||||
|
mv: [NVencMVector; 4],
|
||||||
|
mb_type: u8,
|
||||||
|
partition_type: u8,
|
||||||
|
rsvd: u16,
|
||||||
|
mb_cost: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencHEVCMVData {
|
||||||
|
mv: [NVencMVector; 4],
|
||||||
|
cu_type: u8,
|
||||||
|
cu_size: u8,
|
||||||
|
partition_mode: u8,
|
||||||
|
last_cu_int_ctb: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencCreateMVBuffer {
|
||||||
|
version: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
mv_buffer: *mut c_void,
|
||||||
|
rsvd1: [u32; 254],
|
||||||
|
rsvd2: [*mut c_void; 63],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_CREATE_MV_BUFFER_VER: u32 = struct_version(2);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencQP {
|
||||||
|
qp_inter_p: u32,
|
||||||
|
qp_inter_b: u32,
|
||||||
|
qp_intra: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const MAX_NUM_VIEWS_MINUS_1: usize = 7;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencRcParams {
|
||||||
|
version: u32,
|
||||||
|
pub rate_control_mode: NVencParamsRcMode,
|
||||||
|
const_qp: NVencQP,
|
||||||
|
pub average_bit_rate: u32,
|
||||||
|
max_bit_rate: u32,
|
||||||
|
vbv_buffer_size: u32,
|
||||||
|
vbv_initial_delay: u32,
|
||||||
|
// TODO: bit fields
|
||||||
|
bit_fields: NVencRcParamsBitFlags,
|
||||||
|
min_qp: NVencQP,
|
||||||
|
max_qp: NVencQP,
|
||||||
|
initial_rcqp: NVencQP,
|
||||||
|
temporal_layer_idx_mask: u32,
|
||||||
|
temporal_layer_qp: [u8; 8],
|
||||||
|
target_quality: u8,
|
||||||
|
target_quality_lsb: u8,
|
||||||
|
pub look_ahead_depth: u16,
|
||||||
|
low_delay_key_frame_scale: u8,
|
||||||
|
y_dc_qp_index_offset: i8,
|
||||||
|
u_dc_qp_index_offset: i8,
|
||||||
|
v_dc_qp_index_offset: i8,
|
||||||
|
qp_map_mode: NVencQPMapMode,
|
||||||
|
multi_pass: NVencMultiPass,
|
||||||
|
alpha_layer_bitrate_ratio: u32,
|
||||||
|
cb_qp_index_offset: i8,
|
||||||
|
cr_qp_index_offset: i8,
|
||||||
|
rsvd2: u16,
|
||||||
|
lookahead_level: NVencLookAheadLevel,
|
||||||
|
view_bitrate_ratios: [u8; MAX_NUM_VIEWS_MINUS_1],
|
||||||
|
rsvd3: u8,
|
||||||
|
rsvd1: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct NVencRcParamsBitFlags(u32);
|
||||||
|
|
||||||
|
impl NVencRcParamsBitFlags {}
|
||||||
|
|
||||||
|
pub const NV_ENC_RC_PARAMS_VER: u32 = struct_version(1);
|
||||||
|
|
||||||
|
pub const MAX_NUM_CLOCK_TS: usize = 3;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencClockTimeStampSet {
|
||||||
|
pub bitflags: u32,
|
||||||
|
pub time_offset: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NVencClockTimeStampSet {}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencTimeCode {
|
||||||
|
pub display_pic_struct: NVencDisplayPicStruct,
|
||||||
|
pub clock_timestamp: [NVencClockTimeStampSet; MAX_NUM_CLOCK_TS],
|
||||||
|
pub skip_clock_timestamp_insertion: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const MULTIVIEW_MAX_NUM_REF_DISPLAY: usize = 32;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct HEVC3DReferenceDisplayInfo {
|
||||||
|
// TODO
|
||||||
|
pub bitflags: u32,
|
||||||
|
pub prec_ref_display_width: i32,
|
||||||
|
pub prec_ref_viewing_dist: i32,
|
||||||
|
pub num_ref_displays_minus1: i32,
|
||||||
|
pub left_view_id: [i32; MULTIVIEW_MAX_NUM_REF_DISPLAY],
|
||||||
|
pub right_view_id: [i32; MULTIVIEW_MAX_NUM_REF_DISPLAY],
|
||||||
|
pub exponent_ref_display_width: [i32; MULTIVIEW_MAX_NUM_REF_DISPLAY],
|
||||||
|
pub mantissa_ref_display_width: [i32; MULTIVIEW_MAX_NUM_REF_DISPLAY],
|
||||||
|
pub exponent_ref_viewing_distance: [i32; MULTIVIEW_MAX_NUM_REF_DISPLAY],
|
||||||
|
pub mantissa_ref_viewing_distance: [i32; MULTIVIEW_MAX_NUM_REF_DISPLAY],
|
||||||
|
pub num_sample_shift_plus512: [i32; MULTIVIEW_MAX_NUM_REF_DISPLAY],
|
||||||
|
pub additional_shift_present_flag: [u8; MULTIVIEW_MAX_NUM_REF_DISPLAY],
|
||||||
|
pub rsvd2: [u32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct ChromaPoints {
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct MasteringDisplayInfo {
|
||||||
|
pub g: ChromaPoints,
|
||||||
|
pub b: ChromaPoints,
|
||||||
|
pub r: ChromaPoints,
|
||||||
|
pub white_point: ChromaPoints,
|
||||||
|
pub max_luma: u32,
|
||||||
|
pub min_luma: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct ContentLightLevel {
|
||||||
|
pub max_content_light_level: u16,
|
||||||
|
pub max_pic_average_light_level: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencConfigH264VUIParameters {
|
||||||
|
overscan_info_present_flag: u32,
|
||||||
|
overscan_info: u32,
|
||||||
|
video_signal_type_present_flag: u32,
|
||||||
|
video_format: NVencVUIVideoFormat,
|
||||||
|
video_full_range_flag: u32,
|
||||||
|
color_description_present_flag: u32,
|
||||||
|
color_primaries: NVencVUIColorPrimaries,
|
||||||
|
transfer_characteristics: NVencVUITransCharacteristics,
|
||||||
|
color_matrix: NVencVUIMatrixCoeffs,
|
||||||
|
chroma_sample_location_flag: u32,
|
||||||
|
chroma_sample_location_top: u32,
|
||||||
|
chroma_sample_location_bot: u32,
|
||||||
|
bitstream_restriction_flag: u32,
|
||||||
|
timing_info_present_flag: u32,
|
||||||
|
num_unit_in_ticks: u32,
|
||||||
|
time_scale: u32,
|
||||||
|
rsvd: [u32; 12],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type NVencConfigHEVCVUIParamaters = NVencConfigH264VUIParameters;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct NVencExternalMeHintCountsPerBlockType {
|
||||||
|
pub bitflags: u32,
|
||||||
|
pub rsvd1: [u32; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct NVencExternalMeHint(i32);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencExternalMeSbHint {
|
||||||
|
flags1: i16,
|
||||||
|
flags2: i16,
|
||||||
|
flags3: i16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencConfigH264 {
|
||||||
|
bitflags: u32,
|
||||||
|
level: u32,
|
||||||
|
idr_period: u32,
|
||||||
|
seperate_color_plane_flag: u32,
|
||||||
|
disable_deblocking_filter_idc: u32,
|
||||||
|
num_termpoal_layers: u32,
|
||||||
|
sps_id: u32,
|
||||||
|
pps_id: u32,
|
||||||
|
adaptive_transform_mode: NVencH264AdaptiveTransformMode,
|
||||||
|
fmo_mode: NVencH264FMOMode,
|
||||||
|
bdirect_mode: NVencH264BDirectMode,
|
||||||
|
entropy_coding_mode: NVencH264EntropyCodingMode,
|
||||||
|
stereo_mode: NVencStereoPackingMode,
|
||||||
|
intra_refresh_period: u32,
|
||||||
|
intra_refresh_count: u32,
|
||||||
|
max_num_ref_frames: u32,
|
||||||
|
slice_mode: u32,
|
||||||
|
slice_mode_data: u32,
|
||||||
|
h264_vui_parameters: NVencConfigH264VUIParameters,
|
||||||
|
ltr_num_frames: u32,
|
||||||
|
ltr_trust_mode: u32,
|
||||||
|
chroma_format_idc: u32,
|
||||||
|
max_termpoal_layers: u32,
|
||||||
|
use_b_frames_as_ref: NVencBFrameRefMode,
|
||||||
|
num_ref_l0: NVencNumRefFrames,
|
||||||
|
num_ref_l1: NVencNumRefFrames,
|
||||||
|
output_bit_depth: NVencBitDepth,
|
||||||
|
input_bit_depth: NVencBitDepth,
|
||||||
|
tf_level: NVencTemporalFilterLevel,
|
||||||
|
rsvd1: [u32; 264],
|
||||||
|
rsvd: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencConfigHEVC {
|
||||||
|
level: u32,
|
||||||
|
tier: u32,
|
||||||
|
min_cu_size: NVencHEVCCuSize,
|
||||||
|
max_cu_size: NVencHEVCCuSize,
|
||||||
|
bitflags: u32,
|
||||||
|
idr_period: u32,
|
||||||
|
intra_refresh_cnt: u32,
|
||||||
|
max_num_ref_frames_in_dpb: u32,
|
||||||
|
ltr_num_frames: u32,
|
||||||
|
vps_id: u32,
|
||||||
|
sps_id: u32,
|
||||||
|
pps_id: u32,
|
||||||
|
slice_mode: u32,
|
||||||
|
slice_mode_data: u32,
|
||||||
|
max_temporal_layers_minus_1: u32,
|
||||||
|
hevc_vui_paramters: NVencConfigHEVCVUIParamaters,
|
||||||
|
ltr_trust_mode: u32,
|
||||||
|
use_b_frames_as_ref: NVencBFrameRefMode,
|
||||||
|
tf_level: NVencTemporalFilterLevel,
|
||||||
|
disable_deblocking_filter_idc: u32,
|
||||||
|
output_bit_depth: NVencBitDepth,
|
||||||
|
input_bit_depth: NVencBitDepth,
|
||||||
|
num_temporal_layers: u32,
|
||||||
|
num_views: u32,
|
||||||
|
rsvd1: [u32; 208],
|
||||||
|
rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_MAX_TILE_COLS_AV1: usize = 64;
|
||||||
|
pub const NV_MAX_TILE_ROWS_AV1: usize = 64;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencFilmGrainParamsAV1 {
|
||||||
|
bitfields: u32,
|
||||||
|
point_y_value: [u8; 14],
|
||||||
|
point_y_scaling: [u8; 14],
|
||||||
|
point_cb_value: [u8; 10],
|
||||||
|
point_cb_scaling: [u8; 10],
|
||||||
|
point_cr_value: [u8; 10],
|
||||||
|
point_cr_scaling: [u8; 10],
|
||||||
|
ar_coeffs_y_plus128: [u8; 24],
|
||||||
|
ar_coeffs_cb_plus128: [u8; 25],
|
||||||
|
ar_coeffs_cr_plus128: [u8; 25],
|
||||||
|
rsvd2: [u8; 2],
|
||||||
|
cb_mult: u8,
|
||||||
|
cb_luma_mult: u8,
|
||||||
|
cb_offset: u16,
|
||||||
|
cr_mult: u8,
|
||||||
|
cr_luma_mult: u8,
|
||||||
|
cr_offset: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencConfigAV1 {
|
||||||
|
level: u32,
|
||||||
|
tier: u32,
|
||||||
|
min_part_size: NVencAV1PartSize,
|
||||||
|
max_part_size: NVencAV1PartSize,
|
||||||
|
bitflags: u32,
|
||||||
|
idr_period: u32,
|
||||||
|
intra_refresh_period: u32,
|
||||||
|
intra_refresh_count: u32,
|
||||||
|
max_num_ref_frames_in_dpb: u32,
|
||||||
|
num_tiles_columns: u32,
|
||||||
|
num_tile_rows: u32,
|
||||||
|
rsvd2: u32,
|
||||||
|
tile_widths: *mut u32,
|
||||||
|
tile_heights: *mut u32,
|
||||||
|
max_temporal_layers_minus_1: u32,
|
||||||
|
color_primaries: NVencVUIColorPrimaries,
|
||||||
|
transfer_characteristics: NVencVUITransCharacteristics,
|
||||||
|
matrix_coefficients: NVencVUIMatrixCoeffs,
|
||||||
|
color_range: u32,
|
||||||
|
chroma_sample_position: u32,
|
||||||
|
use_b_frames_as_ref: NVencBFrameRefMode,
|
||||||
|
film_grain_params: NVencFilmGrainParamsAV1,
|
||||||
|
num_fwd_refs: NVencNumRefFrames,
|
||||||
|
num_bwd_refs: NVencNumRefFrames,
|
||||||
|
input_bit_depth: NVencBitDepth,
|
||||||
|
ltr_num_frames: u32,
|
||||||
|
num_temporal_layers: u32,
|
||||||
|
tf_level: NVencTemporalFilterLevel,
|
||||||
|
rsvd1: [u32; 230],
|
||||||
|
rsvd3: [*mut c_void; 62],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencConfigH264MeOnly {
|
||||||
|
bitflags: u32,
|
||||||
|
rsvd1: [u32; 255],
|
||||||
|
rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencConfigHEVCMeOnly {
|
||||||
|
rsvd: [u32; 256],
|
||||||
|
rsvd1: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub union NVencCodecConfig {
|
||||||
|
h264: ManuallyDrop<NVencConfigH264>,
|
||||||
|
hevc: ManuallyDrop<NVencConfigHEVC>,
|
||||||
|
av1: ManuallyDrop<NVencConfigAV1>,
|
||||||
|
h264_meonly: ManuallyDrop<NVencConfigH264MeOnly>,
|
||||||
|
hevc_meonly: ManuallyDrop<NVencConfigHEVCMeOnly>,
|
||||||
|
rsvd: [u32; 320],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencConfig {
|
||||||
|
pub version: u32,
|
||||||
|
pub profile_guid: Guid,
|
||||||
|
pub gop_len: u32,
|
||||||
|
pub frame_interval_p: i32,
|
||||||
|
mono_chrome_encoding: u32,
|
||||||
|
frame_field_mode: NVencParamsFrameFieldMode,
|
||||||
|
mv_precision: NVencMVPrecision,
|
||||||
|
pub rc_params: NVencRcParams,
|
||||||
|
encode_codec_config: NVencCodecConfig,
|
||||||
|
rsvd: [u32; 278],
|
||||||
|
rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_CONFIG_VER: u32 = struct_version(9) | (1 << 31);
|
||||||
|
|
||||||
|
#[bitfields::bitfield(u32)]
|
||||||
|
pub struct NVencInitializeParamsBitfields {
|
||||||
|
#[bits(1)]
|
||||||
|
pub report_slice_offsets: bool,
|
||||||
|
#[bits(1)]
|
||||||
|
pub enable_sub_frame_write: bool,
|
||||||
|
#[bits(1)]
|
||||||
|
pub enable_external_me_hints: bool,
|
||||||
|
#[bits(1)]
|
||||||
|
pub enable_me_only_mode: bool,
|
||||||
|
#[bits(1)]
|
||||||
|
pub enable_weighted_prediction: bool,
|
||||||
|
#[bits(4)]
|
||||||
|
pub split_encode_mode: u8,
|
||||||
|
#[bits(1)]
|
||||||
|
pub enable_output_in_vid_mem: bool,
|
||||||
|
#[bits(1)]
|
||||||
|
pub enable_recon_frame_output: bool,
|
||||||
|
#[bits(1)]
|
||||||
|
pub enable_output_stats: bool,
|
||||||
|
#[bits(1)]
|
||||||
|
pub enable_uni_directional_b: bool,
|
||||||
|
#[bits(19)]
|
||||||
|
pub rsvd: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencInitializeParams {
|
||||||
|
pub version: u32,
|
||||||
|
pub encode_guid: Guid,
|
||||||
|
pub preset_guid: Guid,
|
||||||
|
pub encode_width: u32,
|
||||||
|
pub encode_height: u32,
|
||||||
|
pub dar_width: u32,
|
||||||
|
pub dar_height: u32,
|
||||||
|
pub frame_rate_num: u32,
|
||||||
|
pub frame_rate_den: u32,
|
||||||
|
pub enable_encode_async: u32,
|
||||||
|
pub enable_ptd: u32,
|
||||||
|
pub bit_flags: NVencInitializeParamsBitfields,
|
||||||
|
pub(crate) priv_data_size: u32,
|
||||||
|
pub(crate) rsvd: u32,
|
||||||
|
pub(crate) priv_data: *mut c_void,
|
||||||
|
pub encode_config: *mut NVencConfig,
|
||||||
|
pub max_encode_width: u32,
|
||||||
|
pub max_encode_height: u32,
|
||||||
|
pub max_me_hint_counts_per_block: [NVencExternalMeHintCountsPerBlockType; 2],
|
||||||
|
pub tuning_info: NVencTuningInfo,
|
||||||
|
pub buffer_format: NVencBufferFormat,
|
||||||
|
pub num_state_buffers: u32,
|
||||||
|
pub output_stats_level: NVencOutputStatsLevel,
|
||||||
|
pub(crate) rsvd1: [u32; 284],
|
||||||
|
pub(crate) rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_INITIALIZE_PARAMS_VER: u32 = struct_version(7) | (1 << 31);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencReconfigureParams {
|
||||||
|
version: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
re_init_encode_params: NVencInitializeParams,
|
||||||
|
bitflags: u32,
|
||||||
|
rsvd2: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_RECONFIGURE_PARAMS_VER: u32 = struct_version(2) | (1 << 31);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencPresetConfig {
|
||||||
|
pub version: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
pub preset_cfg: NVencConfig,
|
||||||
|
rsvd1: [u32; 256],
|
||||||
|
rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_PRESET_CONFIG_VER: u32 = struct_version(5) | (1 << 31);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencPicParamsMVC {
|
||||||
|
version: u32,
|
||||||
|
view_id: u32,
|
||||||
|
temporal_id: u32,
|
||||||
|
priority_id: u32,
|
||||||
|
rsvd1: [u32; 12],
|
||||||
|
rsvd2: [*mut c_void; 8],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_PIC_PARAMS_MVC_VER: u32 = struct_version(1);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub union NVencPicParamsH264EXT {
|
||||||
|
pub mvc_pic_params: ManuallyDrop<NVencPicParamsMVC>,
|
||||||
|
pub rsvd1: [u32; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencSeiPayload {
|
||||||
|
pub payload_size: u32,
|
||||||
|
pub payload_type: u32,
|
||||||
|
pub payload: *mut u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type NVencH264SeiPayload = NVencSeiPayload;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencPicParamsH264 {
|
||||||
|
pub display_poc_syntax: u32,
|
||||||
|
pub rsvd3: u32,
|
||||||
|
pub ref_pic_flag: u32,
|
||||||
|
pub color_plane_id: u32,
|
||||||
|
pub force_intra_refresh_with_frame_count: u32,
|
||||||
|
pub bitflags: u32,
|
||||||
|
pub slice_type_data: *mut u8,
|
||||||
|
pub slice_type_array_count: u32,
|
||||||
|
pub sei_payload_array_count: u32,
|
||||||
|
pub sei_payload_array: *mut NVencSeiPayload,
|
||||||
|
pub slice_mode: u32,
|
||||||
|
pub slice_mode_data: u32,
|
||||||
|
pub ltr_mark_framce_idx: u32,
|
||||||
|
pub ltr_use_frame_bitmap: u32,
|
||||||
|
pub ltr_usage_mode: u32,
|
||||||
|
pub force_intra_slice_count: u32,
|
||||||
|
pub force_intra_slice_idx: *mut u32,
|
||||||
|
pub h264_ext_pic_params: NVencPicParamsH264EXT,
|
||||||
|
pub time_code: NVencTimeCode,
|
||||||
|
pub rsvd: [u32; 202],
|
||||||
|
pub rsvd2: [*mut c_void; 61],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencPicParamsHEVC {
|
||||||
|
pub display_poc_syntax: u32,
|
||||||
|
pub ref_pic_flag: u32,
|
||||||
|
pub temporal_id: u32,
|
||||||
|
pub force_intra_refresh_with_frame_count: u32,
|
||||||
|
pub bitflags: u32,
|
||||||
|
pub rsvd1: u32,
|
||||||
|
pub slice_type_data: *mut u8,
|
||||||
|
pub slice_type_array_count: u32,
|
||||||
|
pub slice_mode: u32,
|
||||||
|
pub slice_mode_data: u32,
|
||||||
|
pub ltr_mark_frame_idx: u32,
|
||||||
|
pub ltr_usage_frame_bitmap: u32,
|
||||||
|
pub ltr_usage_mode: u32,
|
||||||
|
pub sei_payload_array_count: u32,
|
||||||
|
pub rsvd: u32,
|
||||||
|
pub sei_payload_array: *mut NVencSeiPayload,
|
||||||
|
pub time_code: NVencTimeCode,
|
||||||
|
pub num_temporal_layers: u32,
|
||||||
|
pub view_id: u32,
|
||||||
|
pub _3d_reference_display_info: *mut HEVC3DReferenceDisplayInfo,
|
||||||
|
pub max_cll: *mut ContentLightLevel,
|
||||||
|
pub mastering_display: *mut MasteringDisplayInfo,
|
||||||
|
pub rsvd2: [u32; 234],
|
||||||
|
pub rsvd3: [*mut c_void; 58],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type NVencAV1OBUPayload = NVencSeiPayload;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencPicParamsAV1 {
|
||||||
|
display_poc_syntax: u32,
|
||||||
|
ref_pic_flag: u32,
|
||||||
|
temporal_id: u32,
|
||||||
|
force_intra_refresh_with_frame_count: u32,
|
||||||
|
bitflags: u32,
|
||||||
|
num_tile_columns: u32,
|
||||||
|
num_tile_rows: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
tile_widths: *mut u32,
|
||||||
|
tile_heights: *mut u32,
|
||||||
|
obu_payload_array_count: u32,
|
||||||
|
rsvd1: u32,
|
||||||
|
obu_payload_array: *mut NVencAV1OBUPayload,
|
||||||
|
file_grain_params: *mut NVencFilmGrainParamsAV1,
|
||||||
|
ltr_mark_frame_idx: u32,
|
||||||
|
ltr_use_frame_bitmap: u32,
|
||||||
|
num_temporal_layers: u32,
|
||||||
|
rsvd4: u32,
|
||||||
|
max_cll: *mut ContentLightLevel,
|
||||||
|
mastering_display: *mut MasteringDisplayInfo,
|
||||||
|
rsvd2: [u32; 242],
|
||||||
|
rsvd3: [*mut c_void; 59],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub union NVencCodecPicParams {
|
||||||
|
pub h264_pic_params: ManuallyDrop<NVencPicParamsH264>,
|
||||||
|
pub hevc_pic_params: ManuallyDrop<NVencPicParamsHEVC>,
|
||||||
|
av1_pic_params: ManuallyDrop<NVencPicParamsAV1>,
|
||||||
|
pub rsvd: [u32; 256],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencPicParams {
|
||||||
|
pub version: u32,
|
||||||
|
pub input_width: u32,
|
||||||
|
pub input_height: u32,
|
||||||
|
pub input_pitch: u32,
|
||||||
|
pub encode_pic_flags: u32,
|
||||||
|
pub frame_idx: u32,
|
||||||
|
pub input_time_stamp: u64,
|
||||||
|
input_duration: u64,
|
||||||
|
pub input_buffer: *mut c_void,
|
||||||
|
pub output_bitstream: *mut c_void,
|
||||||
|
// TODO: Windows only, needed for async
|
||||||
|
completion_event: *mut c_void,
|
||||||
|
pub buffer_format: NVencBufferFormat,
|
||||||
|
pub picture_struct: NVencPicStruct,
|
||||||
|
pub picture_type: NVencPicType,
|
||||||
|
pub codec_pic_params: NVencCodecPicParams,
|
||||||
|
me_hint_counts_per_block: [NVencExternalMeHintCountsPerBlockType; 2],
|
||||||
|
me_external_hints: *mut NVencExternalMeHint,
|
||||||
|
rsvd2: [u32; 7],
|
||||||
|
rsvd5: [*mut c_void; 2],
|
||||||
|
qp_delta_map: *mut i8,
|
||||||
|
qp_deltra_map_size: u32,
|
||||||
|
rsvd_bit_fields: u32,
|
||||||
|
me_hint_ref_pic_dist: [u16; 2],
|
||||||
|
rsvd4: u32,
|
||||||
|
alpha_buffer: *mut c_void,
|
||||||
|
me_external_sb_hints: *mut NVencExternalMeSbHint,
|
||||||
|
me_sb_hints_count: u32,
|
||||||
|
state_buffer_idx: u32,
|
||||||
|
output_recon_buffer: *mut c_void,
|
||||||
|
rsvd3: [u32; 284],
|
||||||
|
rsvd6: [*mut c_void; 57],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_PIC_PARAMS_VER: u32 = struct_version(7) | (1 << 31);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencMeOnlyParams {
|
||||||
|
version: u32,
|
||||||
|
input_width: u32,
|
||||||
|
input_height: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
input_buffer: *mut c_void,
|
||||||
|
reference_frame: *mut c_void,
|
||||||
|
mv_buffer: *mut c_void,
|
||||||
|
rsvd2: u32,
|
||||||
|
buffer_formtat: NVencBufferFormat,
|
||||||
|
view_id: u32,
|
||||||
|
me_hint_counts_per_block: [NVencExternalMeHintCountsPerBlockType; 2],
|
||||||
|
me_external_hints: NVencExternalMeHint,
|
||||||
|
rsvd1: [u32; 241],
|
||||||
|
rsvd3: [*mut c_void; 59],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_MEONLY_PARAMS_VER: u32 = struct_version(4);
|
||||||
|
|
||||||
|
#[bitfields::bitfield(u32)]
|
||||||
|
pub struct NVencLockBitStreamBitFields {
|
||||||
|
#[bits(1)]
|
||||||
|
pub do_not_wait: bool,
|
||||||
|
#[bits(1)]
|
||||||
|
pub ltr_frame: bool,
|
||||||
|
#[bits(1)]
|
||||||
|
pub get_rc_stats: bool,
|
||||||
|
#[bits(29)]
|
||||||
|
rsvd: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencLockBitStream {
|
||||||
|
pub version: u32,
|
||||||
|
pub bit_fields: NVencLockBitStreamBitFields,
|
||||||
|
pub output_bit_stream: *mut c_void,
|
||||||
|
slice_offsets: *mut u32,
|
||||||
|
frame_idx: u32,
|
||||||
|
hw_encode_status: u32,
|
||||||
|
num_slices: u32,
|
||||||
|
pub bitstream_size_in_bytes: u32,
|
||||||
|
output_time_stamp: u64,
|
||||||
|
pub output_duration: u64,
|
||||||
|
pub bitstream_buffer: *mut c_void,
|
||||||
|
picture_type: NVencPicType,
|
||||||
|
picture_struct: NVencPicStruct,
|
||||||
|
framge_avg_qp: u32,
|
||||||
|
frame_satd: u32,
|
||||||
|
ltr_frame_idx: u32,
|
||||||
|
ltr_frame_bitmap: u32,
|
||||||
|
temporal_id: u32,
|
||||||
|
inter_mb_count: u32,
|
||||||
|
average_mvx: i32,
|
||||||
|
average_mvy: i32,
|
||||||
|
alpha_layer_size_in_bytes: u32,
|
||||||
|
output_stats_ptr_size: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
output_stats_ptr: *mut c_void,
|
||||||
|
frame_idx_display: u32,
|
||||||
|
rsvd1: [u32; 219],
|
||||||
|
rsvd2: [*mut c_void; 63],
|
||||||
|
rsvd_interanl: [u32; 8],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_LOCK_BITSTREAM_VER: u32 = struct_version(2) | (1 << 31);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencLockInputBuffer {
|
||||||
|
pub version: u32,
|
||||||
|
pub bitflags: u32,
|
||||||
|
pub input_buffer: *mut c_void,
|
||||||
|
pub buffer_data_ptr: *mut c_void,
|
||||||
|
pub pitch: u32,
|
||||||
|
pub rsvd1: [u32; 251],
|
||||||
|
pub rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_LOCK_INPUT_BUFFER_VER: u32 = struct_version(1);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencMapInputResource {
|
||||||
|
pub version: u32,
|
||||||
|
sub_resource_idx: u32,
|
||||||
|
input_resource: *mut c_void,
|
||||||
|
pub registered_resource: *mut c_void,
|
||||||
|
pub mapped_resource: *mut c_void,
|
||||||
|
pub mapped_buffer_format: NVencBufferFormat,
|
||||||
|
rsvd1: [u32; 251],
|
||||||
|
rsvd: [*mut c_void; 63],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_MAP_INPUT_RESOURCE_VER: u32 = struct_version(4);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencInputResourceOpenGLTex {
|
||||||
|
texture: u32,
|
||||||
|
target: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencFencePointD3D12 {
|
||||||
|
version: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
p_fence: *mut c_void,
|
||||||
|
wait_value: u64,
|
||||||
|
signal_value: u64,
|
||||||
|
bit_flags: u32,
|
||||||
|
rsvd1: [u32; 7],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_FENCE_POINT_D3D12_VER: u32 = struct_version(1);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencInputResourceD3D12 {
|
||||||
|
version: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
input_buffer: *mut c_void,
|
||||||
|
input_fence_point: NVencFencePointD3D12,
|
||||||
|
rsvd1: [u32; 16],
|
||||||
|
rsvd2: [*mut c_void; 16],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_INPUT_RESOURCE_D3D12_VER: u32 = struct_version(1);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencOutputResourceD3D12 {
|
||||||
|
version: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
output_buffer: *mut c_void,
|
||||||
|
output_fence_point: NVencFencePointD3D12,
|
||||||
|
rsvd1: [u32; 16],
|
||||||
|
rsvd2: [*mut c_void; 16],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_OUTPUT_RESOURCE_D3D12_VER: u32 = struct_version(1);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencRegisterResource {
|
||||||
|
pub version: u32,
|
||||||
|
pub resource_type: NVencInputResourceType,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub pitch: u32,
|
||||||
|
pub sub_resource_index: u32,
|
||||||
|
pub resource_to_register: *mut c_void,
|
||||||
|
pub registered_resource: *mut c_void,
|
||||||
|
pub buffer_format: NVencBufferFormat,
|
||||||
|
pub buffer_usage: NVencBufferUsage,
|
||||||
|
pub input_fence_point: *mut NVencFencePointD3D12,
|
||||||
|
pub chroma_offset: [u32; 2],
|
||||||
|
pub chroma_offset_in: [u32; 2],
|
||||||
|
pub(crate) rsvd1: [u32; 244],
|
||||||
|
pub(crate) rsvd2: [*mut c_void; 61],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_REGISTER_RESOURCE_VER: u32 = struct_version(5);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencStat {
|
||||||
|
version: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
output_bit_stream: *mut c_void,
|
||||||
|
bit_stream_size: u32,
|
||||||
|
pic_type: u32,
|
||||||
|
last_valid_byte_offset: u32,
|
||||||
|
slice_offsets: [u32; 16],
|
||||||
|
pic_idx: u32,
|
||||||
|
frame_avg_qp: u32,
|
||||||
|
bit_flags: u32,
|
||||||
|
ltr_frame_idx: u32,
|
||||||
|
intra_mb_count: u32,
|
||||||
|
inter_mb_count: u32,
|
||||||
|
average_mv_x: i32,
|
||||||
|
average_mv_y: i32,
|
||||||
|
rsvd1: [u32; 227],
|
||||||
|
rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_STAT_VER: u32 = struct_version(2);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencSequenceParamPayload {
|
||||||
|
version: u32,
|
||||||
|
in_buffer_size: u32,
|
||||||
|
sps_id: u32,
|
||||||
|
pps_id: u32,
|
||||||
|
spspps_buffer: *mut c_void,
|
||||||
|
output_spspps_payload_size: *mut u32,
|
||||||
|
rsvd: [u32; 250],
|
||||||
|
rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_SEQUENCE_PARAM_PAYLOAD_VER: u32 = struct_version(1);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencEventParams {
|
||||||
|
version: u32,
|
||||||
|
rsvd: u32,
|
||||||
|
completion_event: *mut c_void,
|
||||||
|
rsvd1: [u32; 254],
|
||||||
|
rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_EVENT_PARAMS_VER: u32 = struct_version(2);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct NVencOpenEncodeSessionExParams {
|
||||||
|
pub version: u32,
|
||||||
|
pub device_type: NVencDeviceType,
|
||||||
|
pub device: *mut c_void,
|
||||||
|
rsvd: *mut c_void,
|
||||||
|
pub api_version: u32,
|
||||||
|
rsvd1: [u32; 253],
|
||||||
|
rsvd2: [*mut c_void; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER: u32 = struct_version(1);
|
||||||
12
vendor/nvenc/src/sys/version.rs
vendored
Normal file
12
vendor/nvenc/src/sys/version.rs
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
//! Contains the nvenc version numbers, encoded as they are in C, for the version this crate was built against
|
||||||
|
|
||||||
|
pub const NVENC_MAJOR_VERSION: u16 = 13;
|
||||||
|
pub const NVENC_MINOR_VERSION: u8 = 0;
|
||||||
|
|
||||||
|
pub const NVENC_API_VERSION: u32 = NVENC_MAJOR_VERSION as u32 | (NVENC_MINOR_VERSION as u32) << 24;
|
||||||
|
|
||||||
|
pub const NVENC_API_LIST_VERSION: u32 = struct_version(2);
|
||||||
|
|
||||||
|
pub const fn struct_version(nvenc_api_version: u8) -> u32 {
|
||||||
|
NVENC_API_VERSION | (nvenc_api_version as u32) << 16 | 0x7 << 28
|
||||||
|
}
|
||||||
30
viewer-core/Cargo.toml
Normal file
30
viewer-core/Cargo.toml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "viewer-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# 阶段 1 先做纯 Rust 库并在桌面 headless 验证;
|
||||||
|
# UniFFI / cdylib 在下一步(Android 接入)再加,隔离未知项。
|
||||||
|
[lib]
|
||||||
|
name = "viewer_core"
|
||||||
|
crate-type = ["lib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
anyhow.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
quinn.workspace = true
|
||||||
|
rustls.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# headless_check 用软解交叉验证收到的码流合法(库本身刻意不依赖 openh264,
|
||||||
|
# 解码在 Android 侧由 MediaCodec 承担)
|
||||||
|
openh264 = { version = "0.9.9", features = ["source"] }
|
||||||
|
# rel_mouse_proof(3b/V-B1)需要读回 Windows 光标位置来闭环验证相对注入。
|
||||||
|
# 仅 examples 使用,不进库本体;库本身仍然平台无关。
|
||||||
|
[target.'cfg(windows)'.dev-dependencies]
|
||||||
|
windows = { workspace = true, features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
"Win32_UI_HiDpi",
|
||||||
|
] }
|
||||||
149
viewer-core/examples/headless_check.rs
Normal file
149
viewer-core/examples/headless_check.rs
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
//! viewer-core 的桌面 headless 验证工具(阶段 1 验收用,不依赖 Android)。
|
||||||
|
//!
|
||||||
|
//! 证明三件事,逐条打印:
|
||||||
|
//! 1. viewer-core 能完成 QUIC 连接 + 协议 v2 认证握手;
|
||||||
|
//! 2. 帧流按契约交付(seq 单调递增、keyframe 标志与码流实际 NAL 一致);
|
||||||
|
//! 3. 收到的码流能被 openh264 解出画面(即:Android 侧 MediaCodec 将拿到同样
|
||||||
|
//! 合法的 Annex-B——解码器不同,但码流合法性在这里就证明了)。
|
||||||
|
//!
|
||||||
|
//! 用法:
|
||||||
|
//! 终端1: target\release\agent.exe --port 47900 --code t
|
||||||
|
//! 终端2: cargo run -p viewer-core --bin headless_check -- --port 47900 --code t --frames 60
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::time::Duration;
|
||||||
|
use viewer_core::{ConnectConfig, Session};
|
||||||
|
|
||||||
|
fn arg(name: &str) -> Option<String> {
|
||||||
|
let a: Vec<String> = std::env::args().collect();
|
||||||
|
a.iter()
|
||||||
|
.position(|x| x == name)
|
||||||
|
.and_then(|i| a.get(i + 1))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与 dump_stream 同款:扫 Annex-B 起始码拿 NAL 类型,交叉校验 keyframe 标志
|
||||||
|
fn has_idr(payload: &[u8]) -> bool {
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 3 < payload.len() {
|
||||||
|
let sc = if payload[i..].starts_with(&[0, 0, 0, 1]) {
|
||||||
|
4
|
||||||
|
} else if payload[i..].starts_with(&[0, 0, 1]) {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match payload.get(i + sc) {
|
||||||
|
Some(&b) if b & 0x1F == 5 => return true,
|
||||||
|
Some(_) => i += sc + 1,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let port: u16 = arg("--port")
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(47800);
|
||||||
|
let code = arg("--code").unwrap_or_else(|| "123456".into());
|
||||||
|
// --host:默认 127.0.0.1;填局域网 IP 可顺带验证防火墙入站路径
|
||||||
|
let host = arg("--host").unwrap_or_else(|| "127.0.0.1".into());
|
||||||
|
let frames_wanted: u64 = arg("--frames")
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(60);
|
||||||
|
|
||||||
|
let mut session = Session::connect(ConnectConfig::direct(&host, port, code)).await?;
|
||||||
|
println!("[check] 连接成功 host={host} peer={}", session.peer_addr());
|
||||||
|
|
||||||
|
let mut decoder = openh264::decoder::Decoder::new()?;
|
||||||
|
let mut last_seq: Option<u64> = None;
|
||||||
|
let mut got = 0u64;
|
||||||
|
let mut seq_violations = 0u64;
|
||||||
|
let mut kf_mismatches = 0u64;
|
||||||
|
let mut decoded = 0u64;
|
||||||
|
let mut decode_failed_before_idr = 0u64;
|
||||||
|
let mut awaiting_idr = true; // 与 Android 侧相同的状态机
|
||||||
|
let mut rtt_shown = false;
|
||||||
|
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||||
|
while got < frames_wanted && std::time::Instant::now() < deadline {
|
||||||
|
let Some(f) = tokio::time::timeout(Duration::from_secs(10), session.next_frame())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
got += 1;
|
||||||
|
|
||||||
|
// 契约 2a:seq 单调递增
|
||||||
|
if let Some(prev) = last_seq {
|
||||||
|
if f.seq <= prev {
|
||||||
|
seq_violations += 1;
|
||||||
|
println!("[check] ⚠️ seq 回退: {prev} -> {}", f.seq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last_seq = Some(f.seq);
|
||||||
|
|
||||||
|
// 契约 2b:keyframe 标志与码流一致
|
||||||
|
let real_kf = has_idr(&f.payload);
|
||||||
|
if f.keyframe != real_kf {
|
||||||
|
kf_mismatches += 1;
|
||||||
|
println!(
|
||||||
|
"[check] ⚠️ 帧头 keyframe={} 但码流实际 IDR={}(seq={})",
|
||||||
|
f.keyframe, real_kf, f.seq
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 契约 3:与 Android 相同的状态机下解码必须成功
|
||||||
|
if awaiting_idr && !f.keyframe {
|
||||||
|
continue; // 等 IDR,P 帧不喂(这是 R-3 教训的实现)
|
||||||
|
}
|
||||||
|
if f.keyframe {
|
||||||
|
awaiting_idr = false;
|
||||||
|
}
|
||||||
|
match decoder.decode(&f.payload) {
|
||||||
|
Ok(_) => decoded += 1,
|
||||||
|
Err(e) => {
|
||||||
|
decode_failed_before_idr += 1;
|
||||||
|
println!("[check] ❌ seq={} 解码失败: {e}", f.seq);
|
||||||
|
awaiting_idr = true;
|
||||||
|
session.request_keyframe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RTT 出现即记录一次
|
||||||
|
if !rtt_shown {
|
||||||
|
let s = session.stats();
|
||||||
|
if s.rtt_ms > 0 {
|
||||||
|
println!("[check] RTT={}ms frames_in={} bytes_in={}", s.rtt_ms, s.frames_in, s.bytes_in);
|
||||||
|
rtt_shown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let s = session.stats();
|
||||||
|
println!("\n========== 阶段 1 headless 验收 ==========");
|
||||||
|
println!("收到帧: {got}(目标 {frames_wanted})");
|
||||||
|
println!("seq 违例: {seq_violations}");
|
||||||
|
println!("keyframe 标志不符: {kf_mismatches}");
|
||||||
|
println!("解码成功: {decoded} / 解码失败: {decode_failed_before_idr}");
|
||||||
|
println!("统计: rtt={}ms frames_in={} bytes_in={}", s.rtt_ms, s.frames_in, s.bytes_in);
|
||||||
|
|
||||||
|
let ok = got >= frames_wanted.min(30)
|
||||||
|
&& seq_violations == 0
|
||||||
|
&& kf_mismatches == 0
|
||||||
|
&& decoded > 0
|
||||||
|
&& decode_failed_before_idr == 0;
|
||||||
|
println!(
|
||||||
|
"结论: {}",
|
||||||
|
if ok { "✅ viewer-core 收帧链路通过" } else { "❌ 存在未满足项" }
|
||||||
|
);
|
||||||
|
session.close().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
185
viewer-core/examples/rel_mouse_proof.rs
Normal file
185
viewer-core/examples/rel_mouse_proof.rs
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
//! 3b 桌面验收:相对鼠标(协议 v4)的像素级闭环。
|
||||||
|
//!
|
||||||
|
//! 为什么必须单独写一个 proof:V-B1 的判据是「位移**恰好**等于请求值」,
|
||||||
|
//! 而 Android 侧的真机脚本只能看到 UI 文本,看不到远端指针实际走了多少像素。
|
||||||
|
//! 这里直接走完整链路:viewer-core 发 `MouseMoveRel` → QUIC → agent 注入 →
|
||||||
|
//! `GetCursorPos` 读回,逐项比对。
|
||||||
|
//!
|
||||||
|
//! 覆盖判据:
|
||||||
|
//! - V-B1a 方向与量值:dx=+50 → +50px,dx=-50 → -50px(误差 ≤1px)
|
||||||
|
//! - V-B1b 慢速不丢位移:连发 20 次 dx=+1 → 累计 +20px(误差 ≤2px)
|
||||||
|
//! - V-B1c 撞边不积债:右边界 +200 后 -50 → 立即 -50
|
||||||
|
//!
|
||||||
|
//! 用法(agent 需已启动):
|
||||||
|
//! cargo run --release -p viewer-core --example rel_mouse_proof -- --host 127.0.0.1 --port 47800 --code t
|
||||||
|
//!
|
||||||
|
//! 安全:运行前记录光标原位,结束时还原(不污染用户桌面状态)。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
use viewer_core::{ConnectConfig, Session};
|
||||||
|
|
||||||
|
fn arg(name: &str) -> Option<String> {
|
||||||
|
let a: Vec<String> = std::env::args().collect();
|
||||||
|
a.iter()
|
||||||
|
.position(|x| x == name)
|
||||||
|
.and_then(|i| a.get(i + 1))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读当前光标(物理像素)。用 windows crate 而不是 shell,避免进程 DPI 差异。
|
||||||
|
fn cursor() -> (i32, i32) {
|
||||||
|
use windows::Win32::Foundation::POINT;
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
|
||||||
|
let mut p = POINT::default();
|
||||||
|
unsafe {
|
||||||
|
let _ = GetCursorPos(&mut p);
|
||||||
|
}
|
||||||
|
(p.x, p.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_cursor(x: i32, y: i32) {
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::SetCursorPos;
|
||||||
|
unsafe {
|
||||||
|
let _ = SetCursorPos(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen_size() -> (i32, i32) {
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN};
|
||||||
|
unsafe { (GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enable_dpi_awareness() {
|
||||||
|
use windows::Win32::UI::HiDpi::{
|
||||||
|
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发一串相对增量,每次之间留出注入完成的时间,然后读回光标。
|
||||||
|
fn apply_rel(session: &Session, steps: &[(i16, i16)], settle_ms: u64) {
|
||||||
|
for &(dx, dy) in steps {
|
||||||
|
session.send_mouse_rel(dx, dy);
|
||||||
|
// 让 QUIC 上行 + agent 注入 + 光标更新都完成;太小会读到中间态
|
||||||
|
std::thread::sleep(Duration::from_millis(settle_ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
enable_dpi_awareness();
|
||||||
|
let port: u16 = arg("--port").map(|s| s.parse()).transpose()?.unwrap_or(47800);
|
||||||
|
let code = arg("--code").unwrap_or_else(|| "t".into());
|
||||||
|
let host = arg("--host").unwrap_or_else(|| "127.0.0.1".into());
|
||||||
|
|
||||||
|
let (sw, sh) = screen_size();
|
||||||
|
println!("[proof] screen {sw}x{sh} (physical, DPI-aware)");
|
||||||
|
|
||||||
|
let session = Session::connect(ConnectConfig::direct(&host, port, code)).await?;
|
||||||
|
let (dw, dh) = session.desktop_size();
|
||||||
|
println!("[proof] agent desktop {dw}x{dh}");
|
||||||
|
|
||||||
|
// 记录原位,结束时还原(不污染桌面状态)
|
||||||
|
let (ox, oy) = cursor();
|
||||||
|
println!("[proof] cursor origin ({ox},{oy})");
|
||||||
|
|
||||||
|
let mut failures = 0usize;
|
||||||
|
|
||||||
|
// ---- V-B1a 方向与量值 ----
|
||||||
|
println!("\n=== V-B1a direction & magnitude ===");
|
||||||
|
for &delta in &[50i16, -50i16, 120i16, -120i16] {
|
||||||
|
// 每次都从屏幕中心出发,避免被边界 clamp 干扰量值判定
|
||||||
|
set_cursor(sw / 2, sh / 2);
|
||||||
|
std::thread::sleep(Duration::from_millis(60));
|
||||||
|
let (bx, _) = cursor();
|
||||||
|
apply_rel(&session, &[(delta, 0)], 80);
|
||||||
|
let (ax, _) = cursor();
|
||||||
|
let applied = ax - bx;
|
||||||
|
let ok = (applied - delta as i32).abs() <= 1;
|
||||||
|
println!(
|
||||||
|
" dx={delta:+5} from x={bx:4} to x={ax:4} applied={applied:+5} {}",
|
||||||
|
if ok { "PASS" } else { "FAIL" }
|
||||||
|
);
|
||||||
|
if !ok {
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 纵向同理(同一套代码路径,但轴不同,clamp 上界不同)
|
||||||
|
for &delta in &[40i16, -40i16] {
|
||||||
|
set_cursor(sw / 2, sh / 2);
|
||||||
|
std::thread::sleep(Duration::from_millis(60));
|
||||||
|
let (_, by) = cursor();
|
||||||
|
apply_rel(&session, &[(0, delta)], 80);
|
||||||
|
let (_, ay) = cursor();
|
||||||
|
let applied = ay - by;
|
||||||
|
let ok = (applied - delta as i32).abs() <= 1;
|
||||||
|
println!(
|
||||||
|
" dy={delta:+5} from y={by:4} to y={ay:4} applied={applied:+5} {}",
|
||||||
|
if ok { "PASS" } else { "FAIL" }
|
||||||
|
);
|
||||||
|
if !ok {
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- V-B1b 慢速小幅不丢位移 ----
|
||||||
|
println!("\n=== V-B1b slow small steps must not be lost ===");
|
||||||
|
{
|
||||||
|
set_cursor(sw / 2, sh / 2);
|
||||||
|
std::thread::sleep(Duration::from_millis(60));
|
||||||
|
let (bx, _) = cursor();
|
||||||
|
// 20 次 dx=+1:若注入层按 mickey 透传,dx=1 会被加速吞成 0 像素(实测如此)
|
||||||
|
let steps: Vec<(i16, i16)> = (0..20).map(|_| (1i16, 0i16)).collect();
|
||||||
|
apply_rel(&session, &steps, 45);
|
||||||
|
std::thread::sleep(Duration::from_millis(120));
|
||||||
|
let (ax, _) = cursor();
|
||||||
|
let applied = ax - bx;
|
||||||
|
let ok = (applied - 20).abs() <= 2;
|
||||||
|
println!(" 20 x dx=+1 from x={bx} to x={ax} applied={applied:+} (expect +20) {}",
|
||||||
|
if ok { "PASS" } else { "FAIL" });
|
||||||
|
if !ok {
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- V-B1c 撞边不积债 ----
|
||||||
|
println!("\n=== V-B1c edge clamp leaves no step debt ===");
|
||||||
|
{
|
||||||
|
set_cursor(sw - 1, sh / 2);
|
||||||
|
std::thread::sleep(Duration::from_millis(60));
|
||||||
|
let (bx, _) = cursor();
|
||||||
|
apply_rel(&session, &[(200, 0)], 80);
|
||||||
|
let (mx, _) = cursor();
|
||||||
|
let clamped = mx - bx;
|
||||||
|
// 回滑必须立即生效(若累积器"记住"了被 clamp 丢掉的 200px,这里会先空转)
|
||||||
|
apply_rel(&session, &[(-50, 0)], 80);
|
||||||
|
let (ax, _) = cursor();
|
||||||
|
let back = ax - mx;
|
||||||
|
let ok = clamped == 0 && (back + 50).abs() <= 1;
|
||||||
|
println!(
|
||||||
|
" at right edge x={bx}: +200 -> applied {clamped:+} (clamped); -50 -> applied {back:+} {}",
|
||||||
|
if ok { "PASS" } else { "FAIL" }
|
||||||
|
);
|
||||||
|
if !ok {
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 还原光标
|
||||||
|
set_cursor(ox, oy);
|
||||||
|
|
||||||
|
println!("\n========== RESULT ==========");
|
||||||
|
if failures == 0 {
|
||||||
|
println!("REL MOUSE PROOF PASS (all V-B1a/b/c checks)");
|
||||||
|
} else {
|
||||||
|
println!("REL MOUSE PROOF FAIL ({failures} checks failed)");
|
||||||
|
}
|
||||||
|
session.close().await;
|
||||||
|
if failures > 0 {
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
90
viewer-core/examples/text_check.rs
Normal file
90
viewer-core/examples/text_check.rs
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
//! 协议 v3 文本注入链路的桌面真边界验证工具(loop-2b,不依赖 Android)。
|
||||||
|
//!
|
||||||
|
//! 证明的事:`Session::send_text` 经线格式到被控端后,`agent` 的
|
||||||
|
//! `KEYEVENTF_UNICODE` 注入把**同一段 UTF-16 code unit** 送进了前台应用。
|
||||||
|
//! 判据由外部读取目标窗口的实际文本内容(调用方负责),本工具只负责发送。
|
||||||
|
//!
|
||||||
|
//! 用法:
|
||||||
|
//! 终端1: target\release\agent.exe --port 47800 --code t
|
||||||
|
//! 终端2: 把目标应用(记事本)切到前台,然后
|
||||||
|
//! cargo run -p viewer-core --example text_check -- \
|
||||||
|
//! --port 47800 --code t --units 20320,22909,55357,56832
|
||||||
|
//!
|
||||||
|
//! `--units` 是十进制的 UTF-16 code unit 列表(逗号分隔):
|
||||||
|
//! 「你好😀」= 20320,22909,55357,56832(😀 是代理对)。
|
||||||
|
//! 刻意不收自然语言字符串参数:命令行到 Rust 的编码往返会引入与本轮无关的变量,
|
||||||
|
//! 由调用方(PowerShell)从 .NET 字符串取 char[] 更忠实。
|
||||||
|
//!
|
||||||
|
//! `--delay`:连接后等待毫秒再注入。给调用方把目标窗口切到前台的时间——
|
||||||
|
//! SendInput 注入的是**前台**应用,窗口没在前台等于测了个寂寞。
|
||||||
|
//! `--hold`:注入后保持会话的毫秒数,等消息在远端消化完再关连接。
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use std::time::Duration;
|
||||||
|
use viewer_core::{ConnectConfig, Session};
|
||||||
|
|
||||||
|
fn arg(name: &str) -> Option<String> {
|
||||||
|
let a: Vec<String> = std::env::args().collect();
|
||||||
|
a.iter()
|
||||||
|
.position(|x| x == name)
|
||||||
|
.and_then(|i| a.get(i + 1))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_units(spec: &str) -> Result<Vec<u16>> {
|
||||||
|
if spec.trim().is_empty() {
|
||||||
|
bail!(
|
||||||
|
"--units 缺失或为空:本工具要验证的是「有文本送出并被注入」,\
|
||||||
|
空输入没有验证价值(空 Text 在产品路径上由 Session::send_text 拦掉)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
spec.split(',')
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| {
|
||||||
|
s.parse::<u32>()
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| u16::try_from(v).ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("--units 必须是 0..=65535 的十进制数,got {s:?}"))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let port: u16 = arg("--port")
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(47800);
|
||||||
|
let code = arg("--code").unwrap_or_else(|| "123456".into());
|
||||||
|
let host = arg("--host").unwrap_or_else(|| "127.0.0.1".into());
|
||||||
|
let delay = arg("--delay")
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(1500u64);
|
||||||
|
let hold = arg("--hold")
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(800u64);
|
||||||
|
let units = parse_units(&arg("--units").unwrap_or_default())?;
|
||||||
|
|
||||||
|
let session = Session::connect(ConnectConfig::direct(&host, port, &code)).await?;
|
||||||
|
println!(
|
||||||
|
"[text_check] connected peer={} units={}",
|
||||||
|
session.peer_addr(),
|
||||||
|
units.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(delay)).await;
|
||||||
|
session.send_text(&units);
|
||||||
|
println!("[text_check] sent (只报长度,不打印内容)");
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(hold)).await;
|
||||||
|
let st = session.stats();
|
||||||
|
println!(
|
||||||
|
"[text_check] frames={} rtt={}ms — 会话活着,注入已发给前台应用",
|
||||||
|
st.frames_in, st.rtt_ms
|
||||||
|
);
|
||||||
|
session.close().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
858
viewer-core/src/lib.rs
Normal file
858
viewer-core/src/lib.rs
Normal file
@ -0,0 +1,858 @@
|
|||||||
|
//! viewer-core:安卓主控的连接 / 协议 / 收帧层(纯 Rust,平台无关)。
|
||||||
|
//!
|
||||||
|
//! 设计边界(对齐《多平台架构设计》与 2026-10-29 方案裁决):
|
||||||
|
//! - **只做「怎么连 + 收发什么」**:QUIC 会话、认证握手、视频帧流、输入上行、
|
||||||
|
//! Ping/Pong、KeyFrameReq。解码(MediaCodec)、渲染(SurfaceView)、触摸映射
|
||||||
|
//! 全在 Kotlin 侧,本 crate 不碰像素,也不引入 openh264。
|
||||||
|
//! - **连接层是唯一的可替换点**:`PeerTarget::Direct` 现在只支持 `IP:端口` 局域网
|
||||||
|
//! 直连;将来设备码 + 信令 + 打洞只换 `connect()` 内部。**不定义 Relay 变体**
|
||||||
|
//! (中继已被团队裁决排除,见《多平台架构设计》§6.1)。
|
||||||
|
//! - 帧经 mpsc 通道交付,**不丢帧**:消费方(Kotlin)按 `keyframe` 标志自行决定
|
||||||
|
//! 跳帧策略,且跳帧后必须 `request_keyframe()` 并等 IDR(协议 v2 提供该字段)。
|
||||||
|
//! 丢帧决策放在这里会让 Rust 层承担它看不到的解码器状态。
|
||||||
|
//!
|
||||||
|
//! 安全现状:`insecure_skip_verify` 默认 true(与桌面 viewer 的 SkipVerify 一致,
|
||||||
|
//! 仅限局域网测试)。产品化前必须换成信令下发的证书指纹钉扎(`signaling.rs`
|
||||||
|
//! 已有 `CertFingerprint`),该项登记在方案阶段 4。
|
||||||
|
|
||||||
|
use common::{
|
||||||
|
read_control, read_video_frame, write_control, ControlMsg, InputEvent, VideoFrameHeader,
|
||||||
|
PROTO_VERSION,
|
||||||
|
};
|
||||||
|
use rustls::client::danger::{
|
||||||
|
HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier,
|
||||||
|
};
|
||||||
|
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||||
|
use rustls::{DigitallySignedStruct, SignatureScheme};
|
||||||
|
use std::net::ToSocketAddrs;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// QUIC 握手单次尝试预算(第一拨):不可达/被拦路径的失败上限(阶段 3a U8)。
|
||||||
|
/// 与被控端 `agent-core` 的 max_idle_timeout(10s) 同思路——超时策略必须显式、短、可见。
|
||||||
|
pub const CONNECT_BUDGET: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// 第二拨握手预算(重拨专用,8s > 第一拨)。
|
||||||
|
/// 存在理由:agent 串行 accept,僵尸释放实测在「杀端后 ~10.2s + 残骸握手再排
|
||||||
|
/// 5s 档」≈13s 窗口内;第二拨 8s 让握手段天然长于僵尸窗口,一次重拨接住。
|
||||||
|
/// 真·不可达不受此影响——它由总预算封顶报 Timeout。
|
||||||
|
pub const CONNECT_RETRY_BUDGET: Duration = Duration::from_secs(8);
|
||||||
|
|
||||||
|
/// 认证应答预算:握手成功后等 `AuthOk` 的上限。agent 串行 accept(R-5),
|
||||||
|
/// 正常应答是毫秒级;11s 只是异常场景(agent 卡在旧会话收尾)的兜底。
|
||||||
|
pub const AUTH_BUDGET: Duration = Duration::from_secs(11);
|
||||||
|
|
||||||
|
/// connect() 全程硬上限(含重拨)。14s = 5 + 8 两拨握手预算 + 1s 余量:
|
||||||
|
/// 正常路径由**内层**各拨预算先触发(错误 detail 精确到段),外层只兜
|
||||||
|
/// 「重试组合出无界等待」这一种失控。真不可达 → ≈13s 报 Timeout
|
||||||
|
/// (2b 的 32s → 13s 且全程可取消,判据更新见 V-A1)。
|
||||||
|
pub const CONNECT_TOTAL_BUDGET: Duration = Duration::from_secs(14);
|
||||||
|
|
||||||
|
/// 可重拨失败(Timeout/Refused)的最大尝试次数。Resolve/Auth/Version 是
|
||||||
|
/// 确定性结果,重拨只会拖慢反馈,不重试。
|
||||||
|
pub const MAX_CONNECT_ATTEMPTS: usize = 2;
|
||||||
|
|
||||||
|
/// 连接目标。当前只有局域网直连;设备码 + 打洞在后续阶段替换 `connect()` 内部实现。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PeerTarget {
|
||||||
|
/// 局域网 / 可直连地址
|
||||||
|
Direct { host: String, port: u16 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次会话的参数
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ConnectConfig {
|
||||||
|
pub target: PeerTarget,
|
||||||
|
/// 被控端验证码
|
||||||
|
pub code: String,
|
||||||
|
/// 仅测试用:跳过服务端证书校验(与桌面 viewer 行为一致)。
|
||||||
|
/// 公网/产品形态必须为 false 并启用证书钉扎。
|
||||||
|
pub insecure_skip_verify: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectConfig {
|
||||||
|
/// 局域网测试的便捷构造
|
||||||
|
pub fn direct(host: impl Into<String>, port: u16, code: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
target: PeerTarget::Direct {
|
||||||
|
host: host.into(),
|
||||||
|
port,
|
||||||
|
},
|
||||||
|
code: code.into(),
|
||||||
|
insecure_skip_verify: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 连接建立失败的分类(阶段 3a)。
|
||||||
|
///
|
||||||
|
/// 为什么分类在 Rust 侧做而不是 Kotlin:错误来源(DNS / QUIC 握手 / 认证 / 版本)
|
||||||
|
/// 只有这一层看得全;Kotlin 只做 `code → 中文文案` 映射。
|
||||||
|
/// [`ConnectErrorKind::code`] 是 FFI 编号(android-ffi `nativeLastErrorKind` 与 Kotlin
|
||||||
|
/// `ConnectErrorText` 都按它映射),**改动必须三处同步**——FFI 只传整数不传 Rust 类型。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ConnectErrorKind {
|
||||||
|
/// 地址解析失败:host 写错或 DNS 不可达
|
||||||
|
Resolve,
|
||||||
|
/// 连接超时:被控端不在线、IP/端口不对、入站被拦
|
||||||
|
Timeout,
|
||||||
|
/// 被控端异常应答/拒绝:该端口上没有 QUIC 服务、握手被重置
|
||||||
|
Refused,
|
||||||
|
/// 认证失败:验证码错误
|
||||||
|
Auth,
|
||||||
|
/// 协议版本不一致:两端必须重编到同一版本
|
||||||
|
Version,
|
||||||
|
/// 未分类。**detail 原文必须展示**(第二行),不得静默归进某类文案
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectErrorKind {
|
||||||
|
/// FFI 稳定编号。新增项只追加,不复用旧编号(Kotlin `else ->` 兜住未知码)。
|
||||||
|
pub fn code(self) -> i32 {
|
||||||
|
match self {
|
||||||
|
ConnectErrorKind::Resolve => 1,
|
||||||
|
ConnectErrorKind::Timeout => 2,
|
||||||
|
ConnectErrorKind::Refused => 3,
|
||||||
|
ConnectErrorKind::Auth => 4,
|
||||||
|
ConnectErrorKind::Version => 5,
|
||||||
|
ConnectErrorKind::Other => 9,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 连接失败 = 分类 + 原始错误文本。
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ConnectError {
|
||||||
|
pub kind: ConnectErrorKind,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectError {
|
||||||
|
fn new(kind: ConnectErrorKind, detail: impl Into<String>) -> Self {
|
||||||
|
Self { kind, detail: detail.into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ConnectError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ConnectError {}
|
||||||
|
|
||||||
|
/// 未分类错误的统一包装:保留原文(Other 的 detail 会显示到界面第二行,不静默)。
|
||||||
|
fn other_err(e: impl std::fmt::Display) -> ConnectError {
|
||||||
|
ConnectError::new(ConnectErrorKind::Other, e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 认证失败消息 → 分类。判据是 agent 侧的稳定前缀(`agent/src/main.rs` 与
|
||||||
|
/// `agent-core/src/lib.rs` 都以 `protocol version mismatch` 开头);
|
||||||
|
/// 命中不了的一律 `Auth`——宁可提示「验证码错误」也不把内部错误文案当分类依据。
|
||||||
|
fn classify_auth_fail(msg: &str) -> ConnectErrorKind {
|
||||||
|
if msg.contains("protocol version mismatch") {
|
||||||
|
ConnectErrorKind::Version
|
||||||
|
} else {
|
||||||
|
ConnectErrorKind::Auth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步 connect() 调用(本地配置面)的分类:全是 Other(endpoint 停摆/地址非法等),
|
||||||
|
/// 保留原文。单列函数是为了让分类面在测试里可枚举、改动可见。
|
||||||
|
fn classify_connect_error(_e: &quinn::ConnectError) -> ConnectErrorKind {
|
||||||
|
ConnectErrorKind::Other
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 哪些失败值得重拨(3a 方案①)。
|
||||||
|
/// - Timeout/Refused:agent 串行 accept 的排队/残骸窗口是**暂时**的
|
||||||
|
/// (实测僵尸释放 ~10.2s、残骸阻塞握手 5~8.5s),第二拨能接住;
|
||||||
|
/// - Resolve/Auth/Version:确定性结果,重拨只拖慢反馈、不改变答案;
|
||||||
|
/// - Other:语义不明(含钉扎未实现、首消息异常),不猜、不重试。
|
||||||
|
fn is_retryable(kind: ConnectErrorKind) -> bool {
|
||||||
|
matches!(kind, ConnectErrorKind::Timeout | ConnectErrorKind::Refused)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 握手/连接期错误 → 分类。逐个变体对照 quinn 0.11.12 re-export 的
|
||||||
|
/// `quinn::ConnectionError`(quinn-proto 0.11.19 `connection/mod.rs:3906`,本会话实读)。
|
||||||
|
fn classify_connection_error(e: &quinn::ConnectionError) -> ConnectErrorKind {
|
||||||
|
use quinn::ConnectionError as E;
|
||||||
|
match e {
|
||||||
|
E::TimedOut => ConnectErrorKind::Timeout,
|
||||||
|
// 端口上没有 QUIC 服务(回收包/非法头)、被重置、握手后被关:
|
||||||
|
// 用户语境统一是「对端不应答」→ Refused
|
||||||
|
E::TransportError(_) | E::Reset | E::ConnectionClosed(_) | E::ApplicationClosed(_) => {
|
||||||
|
ConnectErrorKind::Refused
|
||||||
|
}
|
||||||
|
// QUIC 传输版本协商失败 ≠ 应用协议版本不一致,不要误导成 Version
|
||||||
|
E::VersionMismatch | E::LocallyClosed | E::CidsExhausted => ConnectErrorKind::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 交付给消费方的一帧视频。字段语义与 `common::VideoFrameHeader` 一致,
|
||||||
|
/// payload 为标准 Annex-B H.264(可直接喂 MediaCodec)。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Frame {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub ts_ms: u64,
|
||||||
|
pub seq: u64,
|
||||||
|
pub keyframe: bool,
|
||||||
|
/// v5:帧发送时刻的远端光标位置(采集画面内物理像素)。
|
||||||
|
/// `None` = 隐藏/离屏/后端未实现(语义单一来源在被控端,见 `VideoFrameHeader::cursor`)。
|
||||||
|
/// 桌面 viewer 不消费该字段(看着自己的鼠标),安卓据它画标记。
|
||||||
|
pub cursor: Option<(i16, i16)>,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(VideoFrameHeader, Vec<u8>)> for Frame {
|
||||||
|
fn from((h, payload): (VideoFrameHeader, Vec<u8>)) -> Self {
|
||||||
|
Self {
|
||||||
|
width: h.width,
|
||||||
|
height: h.height,
|
||||||
|
ts_ms: h.ts_ms,
|
||||||
|
seq: h.seq,
|
||||||
|
keyframe: h.keyframe,
|
||||||
|
cursor: h.cursor,
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话状态快照(供 UI 显示,不做 EMA——那属于 Kotlin 的 HUD 层)
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct SessionStats {
|
||||||
|
/// 最近一次 Ping 往返(ms),0 = 还没测出
|
||||||
|
pub rtt_ms: u64,
|
||||||
|
/// 收到的帧数
|
||||||
|
pub frames_in: u64,
|
||||||
|
/// 收到的字节数(视频载荷累计)
|
||||||
|
pub bytes_in: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MVP:跳过服务端证书校验(自签证书,仅局域网)。
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SkipVerify;
|
||||||
|
impl ServerCertVerifier for SkipVerify {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
_end_entity: &CertificateDer<'_>,
|
||||||
|
_intermediates: &[CertificateDer<'_>],
|
||||||
|
_server_name: &ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: UnixTime,
|
||||||
|
) -> std::result::Result<ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_certificate: &CertificateDer<'_>,
|
||||||
|
_dss: &DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_certificate: &CertificateDer<'_>,
|
||||||
|
_dss: &DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||||
|
vec![
|
||||||
|
SignatureScheme::RSA_PKCS1_SHA256,
|
||||||
|
SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||||
|
SignatureScheme::ED25519,
|
||||||
|
SignatureScheme::RSA_PSS_SHA256,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 输入/控制命令(写入流 2)
|
||||||
|
enum Command {
|
||||||
|
Input(InputEvent),
|
||||||
|
KeyFrameReq,
|
||||||
|
Ping(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已建立的会话。Drop 时后台任务随连接关闭自然退出;显式 `close()` 更干净。
|
||||||
|
pub struct Session {
|
||||||
|
tx: mpsc::UnboundedSender<Command>,
|
||||||
|
/// 帧接收端。通道关闭 = 会话结束(被控端断开或流错误)。
|
||||||
|
/// Mutex 包一层是为了让 `next_frame` 取 `&self`:JNI 层里解码线程独占 recv,
|
||||||
|
/// 输入线程并发调 `send_input`(走无锁的 unbounded tx),两者互不阻塞。
|
||||||
|
frames: Arc<tokio::sync::Mutex<mpsc::Receiver<Frame>>>,
|
||||||
|
stats: Arc<std::sync::Mutex<SessionStats>>,
|
||||||
|
/// 被控端桌面尺寸(AuthOk 携带)。主控端据此在解码前定好视图宽高比——
|
||||||
|
/// 若等首帧再改尺寸会触发 SurfaceView 重新测量,把刚创建的解码器 surface 打掉。
|
||||||
|
desktop: (u32, u32),
|
||||||
|
/// 保持 Endpoint 存活(drop 会销毁连接)
|
||||||
|
_endpoint: quinn::Endpoint,
|
||||||
|
conn: quinn::Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// 建立连接并完成认证握手 + 首帧关键帧请求。
|
||||||
|
///
|
||||||
|
/// **短预算 × 有限重拨**(3a/U8,2026-10-31 用户裁决方案①;演进证据链见
|
||||||
|
/// loop-3-plan 偏差 D2/D3:单次 5s 预算在 agent 串行 accept 架构下自相矛盾——
|
||||||
|
/// 不可达要快失败、僵尸排队要耐心等,同一数字满足不了;拆握手/认证两段也不够,
|
||||||
|
/// 实测阻塞点在握手本身):
|
||||||
|
/// - 每次尝试:握手第一拨 [`CONNECT_BUDGET`](5s)、重拨拨
|
||||||
|
/// [`CONNECT_RETRY_BUDGET`](8s)、认证 [`AUTH_BUDGET`](11s);
|
||||||
|
/// - [`is_retryable`] 的失败(Timeout/Refused)**自动重拨**,共
|
||||||
|
/// [`MAX_CONNECT_ATTEMPTS`](2) 次。存在理由(2026-10-31 两组对照实验):
|
||||||
|
/// agent 串行 accept 期间不泵 socket,新握手被残骸/僵尸阻塞实测 5~8.5s
|
||||||
|
/// (杀会话后必现、干净连发 8 次亦有 1 次);第一拨 5s 被切是预期成本,
|
||||||
|
/// 第二拨的窗口天然落在释放点之后。包级机制未逐行核实(R-15),此处只
|
||||||
|
/// 陈述实测:**第一拨 5s 超时 → 第二拨成功**(kill 场景 total ≈5-11s 内接住)。
|
||||||
|
/// - 全程 [`CONNECT_TOTAL_BUDGET`](13s) 硬上限 = 5+8 完整两拨,
|
||||||
|
/// 防重试组合出无界等待;
|
||||||
|
/// - 真不可达 ≈13s 报 Timeout(修复前 32s),**全程 UI 可取消**——
|
||||||
|
/// U8 判据形态由此更新(plan V-A1)。
|
||||||
|
///
|
||||||
|
/// 已知边界:DNS 解析走同步 `to_socket_addrs`,current-thread runtime 里阻塞调用
|
||||||
|
/// 不能被 timeout 打断;本机实测解析失败是亚秒级。极端 DNS 挂起属阶段 4 网络治理面。
|
||||||
|
pub async fn connect(cfg: ConnectConfig) -> Result<Self, ConnectError> {
|
||||||
|
let attempts = async {
|
||||||
|
let mut attempt = 0usize;
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
// 第一拨短握手(快速判不可达),重拨拨长握手(接住僵尸释放)
|
||||||
|
let handshake = if attempt == 1 {
|
||||||
|
CONNECT_BUDGET
|
||||||
|
} else {
|
||||||
|
CONNECT_RETRY_BUDGET
|
||||||
|
};
|
||||||
|
match Self::connect_once(cfg.clone(), handshake).await {
|
||||||
|
Ok(s) => return Ok(s),
|
||||||
|
Err(e) => {
|
||||||
|
if attempt >= MAX_CONNECT_ATTEMPTS || !is_retryable(e.kind) {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
log(&format!(
|
||||||
|
"connect 第 {attempt}/{MAX_CONNECT_ATTEMPTS} 拨失败: {}(重拨)",
|
||||||
|
e.detail
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 外层硬上限。Timeout::poll 先轮询内层——已成功返回的会话不会被
|
||||||
|
// 「刚好到点」的外层吞掉(D2 的教训:预算必须给合法路径留出完成空间)
|
||||||
|
match tokio::time::timeout(CONNECT_TOTAL_BUDGET, attempts).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => Err(ConnectError::new(
|
||||||
|
ConnectErrorKind::Timeout,
|
||||||
|
format!(
|
||||||
|
"connect exceeded {}s total budget within {MAX_CONNECT_ATTEMPTS} attempts",
|
||||||
|
CONNECT_TOTAL_BUDGET.as_secs()
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_once(cfg: ConnectConfig, handshake_budget: Duration) -> Result<Self, ConnectError> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let tls = if cfg.insecure_skip_verify {
|
||||||
|
rustls::ClientConfig::builder()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(SkipVerify))
|
||||||
|
.with_no_client_auth()
|
||||||
|
} else {
|
||||||
|
// 证书钉扎尚未接入(方案阶段 4)。这里明确报错而不是静默放行。
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
ConnectErrorKind::Other,
|
||||||
|
"insecure_skip_verify=false 需要证书钉扎,尚未实现(见方案阶段 4)",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let qc: quinn::crypto::rustls::QuicClientConfig = Arc::new(tls)
|
||||||
|
.try_into()
|
||||||
|
.map_err(other_err)?;
|
||||||
|
|
||||||
|
// 接收窗口放大:视频下行大流量,quinn 默认 512KB 流窗口会卡吞吐
|
||||||
|
// (与桌面 viewer / agent 的实测调优一致)
|
||||||
|
let mut transport = quinn::TransportConfig::default();
|
||||||
|
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
|
||||||
|
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
|
||||||
|
let mut client_config = quinn::ClientConfig::new(Arc::new(qc));
|
||||||
|
client_config.transport_config(Arc::new(transport));
|
||||||
|
|
||||||
|
let mut endpoint =
|
||||||
|
quinn::Endpoint::client("0.0.0.0:0".parse().map_err(other_err)?).map_err(other_err)?;
|
||||||
|
endpoint.set_default_client_config(client_config);
|
||||||
|
|
||||||
|
let PeerTarget::Direct { host, port } = &cfg.target;
|
||||||
|
let addr = (host.as_str(), *port)
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|e| {
|
||||||
|
ConnectError::new(
|
||||||
|
ConnectErrorKind::Resolve,
|
||||||
|
format!("resolve {host}:{port}: {e}"),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ConnectError::new(
|
||||||
|
ConnectErrorKind::Resolve,
|
||||||
|
format!("resolve {host}:{port}: no record"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let conn = {
|
||||||
|
let connect_fut = endpoint.connect(addr, "localhost").map_err(|e| {
|
||||||
|
ConnectError::new(classify_connect_error(&e), e.to_string())
|
||||||
|
})?;
|
||||||
|
// 单次尝试的握手预算(第一拨 5s、重拨拨 8s,见 connect() 注释)。
|
||||||
|
// 超时归类 Timeout(可重拨);真不可达由重拨打满总预算后报出。
|
||||||
|
match tokio::time::timeout(handshake_budget, connect_fut).await {
|
||||||
|
Ok(Ok(c)) => c,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
classify_connection_error(&e),
|
||||||
|
e.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
ConnectErrorKind::Timeout,
|
||||||
|
format!(
|
||||||
|
"QUIC handshake exceeded {}s budget",
|
||||||
|
handshake_budget.as_secs()
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 流 1:认证 + 视频下行 ----
|
||||||
|
let (mut v_send, mut v_recv) = conn.open_bi().await.map_err(other_err)?;
|
||||||
|
write_control(
|
||||||
|
&mut v_send,
|
||||||
|
&ControlMsg::Auth {
|
||||||
|
code: cfg.code.clone(),
|
||||||
|
proto_version: PROTO_VERSION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(other_err)?;
|
||||||
|
let desktop: (u32, u32);
|
||||||
|
// 认证应答预算 11s:agent 串行 accept,僵尸会话最长 ~10s(R-5)才轮到本请求。
|
||||||
|
// 这一段超时的语义是「连上了但对端一直不应答」——同样归类 Timeout,detail 说清。
|
||||||
|
let first_msg = match tokio::time::timeout(AUTH_BUDGET, read_control(&mut v_recv)).await {
|
||||||
|
Ok(r) => r.map_err(other_err)?,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
ConnectErrorKind::Timeout,
|
||||||
|
format!(
|
||||||
|
"connected but auth reply exceeded {}s (agent busy?)",
|
||||||
|
AUTH_BUDGET.as_secs()
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match first_msg {
|
||||||
|
ControlMsg::AuthOk {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
proto_version,
|
||||||
|
} => {
|
||||||
|
if proto_version != PROTO_VERSION {
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
ConnectErrorKind::Version,
|
||||||
|
format!(
|
||||||
|
"protocol mismatch: agent={proto_version}, viewer-core={PROTO_VERSION}"
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
desktop = (width, height);
|
||||||
|
log(&format!("auth ok, remote desktop {width}x{height}"));
|
||||||
|
}
|
||||||
|
ControlMsg::AuthFail(e) => {
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
classify_auth_fail(&e),
|
||||||
|
format!("auth failed: {e}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
m => {
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
ConnectErrorKind::Other,
|
||||||
|
format!("unexpected first message: {m:?}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 流 2:输入 + 控制 + Ping/Pong ----
|
||||||
|
let (mut ctl_send, mut ctl_recv) = conn.open_bi().await.map_err(other_err)?;
|
||||||
|
// 入会即请求 IDR:从 GOP 中间接入时裸 P 帧解不出来(桌面端同款教训)
|
||||||
|
write_control(&mut ctl_send, &ControlMsg::KeyFrameReq)
|
||||||
|
.await
|
||||||
|
.map_err(other_err)?;
|
||||||
|
|
||||||
|
let stats = Arc::new(std::sync::Mutex::new(SessionStats::default()));
|
||||||
|
|
||||||
|
// 帧通道:容量 2 足够——消费方(Kotlin)每帧都要喂解码器,
|
||||||
|
// 积压超过 2 帧说明该跳帧的是消费方的决策,不是这里的。
|
||||||
|
let (frame_tx, frames) = mpsc::channel::<Frame>(2);
|
||||||
|
{
|
||||||
|
let stats = stats.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match read_video_frame(&mut v_recv).await {
|
||||||
|
Ok((h, payload)) => {
|
||||||
|
{
|
||||||
|
let mut s = stats.lock().unwrap();
|
||||||
|
s.frames_in += 1;
|
||||||
|
s.bytes_in += payload.len() as u64;
|
||||||
|
}
|
||||||
|
let frame: Frame = (h, payload).into();
|
||||||
|
if frame_tx.send(frame).await.is_err() {
|
||||||
|
break; // 消费方已退出
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("video stream ended: {e}"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命令通道 → 流 2 写任务
|
||||||
|
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<Command>();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Some(cmd) = cmd_rx.recv().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let msg = match cmd {
|
||||||
|
Command::Input(ev) => ControlMsg::Input(ev),
|
||||||
|
Command::KeyFrameReq => ControlMsg::KeyFrameReq,
|
||||||
|
Command::Ping(t) => ControlMsg::Ping(t),
|
||||||
|
};
|
||||||
|
if write_control(&mut ctl_send, &msg).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 流 2 读任务:Pong → RTT
|
||||||
|
{
|
||||||
|
let stats = stats.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match read_control(&mut ctl_recv).await {
|
||||||
|
Ok(ControlMsg::Pong { t, .. }) => {
|
||||||
|
let rtt = monotonic_ms().saturating_sub(t);
|
||||||
|
stats.lock().unwrap().rtt_ms = rtt;
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("control stream ended: {e}"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping 任务:500ms 一次(与桌面 viewer 一致)
|
||||||
|
{
|
||||||
|
let cmd_tx_ping = cmd_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
if cmd_tx_ping.send(Command::Ping(monotonic_ms())).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
tx: cmd_tx,
|
||||||
|
frames: Arc::new(tokio::sync::Mutex::new(frames)),
|
||||||
|
stats,
|
||||||
|
desktop,
|
||||||
|
_endpoint: endpoint,
|
||||||
|
conn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 被控端桌面尺寸 `(width, height)`,来自 AuthOk,解码前即可用。
|
||||||
|
pub fn desktop_size(&self) -> (u32, u32) {
|
||||||
|
self.desktop
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取下一帧。`None` = 会话结束。
|
||||||
|
/// `&self`:JNI 层里解码线程独占 recv,输入线程可并发 `send_input`。
|
||||||
|
pub async fn next_frame(&self) -> Option<Frame> {
|
||||||
|
self.frames.lock().await.recv().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送一个输入事件(非阻塞入队,由写任务串行发出)。
|
||||||
|
pub fn send_input(&self, ev: InputEvent) {
|
||||||
|
let _ = self.tx.send(Command::Input(ev));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 协议 v3:发送一段文本(UTF-16 code unit)到被控端注入。
|
||||||
|
///
|
||||||
|
/// 为什么参数是 `&[u16]` 而不是 `&str`:被控端的注入通道(Windows
|
||||||
|
/// `KEYEVENTF_UNICODE`)与主控端来源(Java 字符串)都是 UTF-16 code unit,
|
||||||
|
/// 中间任何一次 UTF-8 往返都会把 IME 提交边界的落单代理项变成替换字符。
|
||||||
|
/// 见 `common::InputEvent::Text` 的取舍说明。
|
||||||
|
///
|
||||||
|
/// 超限处理:截断到 [`common::MAX_TEXT_UNITS`] 并打日志——静默丢弃会让用户
|
||||||
|
/// 以为长文本已送达。日志只报长度,不报内容。
|
||||||
|
pub fn send_text(&self, units: &[u16]) {
|
||||||
|
if units.is_empty() {
|
||||||
|
return; // 空提交没有注入语义,也不该占用一条消息
|
||||||
|
}
|
||||||
|
let (units, truncated) = common::clamp_text_units(units.to_vec());
|
||||||
|
if truncated {
|
||||||
|
log(&format!(
|
||||||
|
"文本超过上限,已截断到 {} 个 code unit",
|
||||||
|
common::MAX_TEXT_UNITS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.send_input(InputEvent::Text { units });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 协议 v4:发送**相对**鼠标位移(单位:被控端桌面物理像素)。
|
||||||
|
///
|
||||||
|
/// 与 [`Session::send_text`] 同样的空值纪律:零位移不占一条消息——它没有注入语义,
|
||||||
|
/// 只会白占一个 QUIC 帧(触摸事件里 `dx==0 && dy==0` 是常态,手指静止时每帧都发)。
|
||||||
|
///
|
||||||
|
/// 语义是「从远端指针当前位置移动 (dx,dy)」,远端负责 clamp 到桌面内。
|
||||||
|
/// 主控端**不要**先算绝对坐标再发——那就退化成绝对模式,失去相对鼠标的意义
|
||||||
|
/// (本机真鼠标同时移动时会跳变)。
|
||||||
|
pub fn send_mouse_rel(&self, dx: i16, dy: i16) {
|
||||||
|
if dx == 0 && dy == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.send_input(InputEvent::MouseMoveRel { dx, dy });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求被控端强制关键帧(解码器错误/跳帧后的恢复路径)。
|
||||||
|
pub fn request_keyframe(&self) {
|
||||||
|
let _ = self.tx.send(Command::KeyFrameReq);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前会话统计快照。
|
||||||
|
pub fn stats(&self) -> SessionStats {
|
||||||
|
*self.stats.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 被控端地址(日志/UI 用)
|
||||||
|
pub fn peer_addr(&self) -> String {
|
||||||
|
self.conn.remote_address().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 主动结束会话。
|
||||||
|
pub async fn close(self) {
|
||||||
|
let _ = self.conn.close(0u32.into(), b"viewer closing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单调毫秒时钟(进程启动起算)。Ping/Pong 两端共用同一口径即可测 RTT。
|
||||||
|
fn monotonic_ms() -> u64 {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static T0: OnceLock<Instant> = OnceLock::new();
|
||||||
|
T0.get_or_init(Instant::now).elapsed().as_millis() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 轻量日志出口。阶段 2 接 UniFFI 时替换为 Kotlin 侧回调。
|
||||||
|
fn log(msg: &str) {
|
||||||
|
eprintln!("[viewer-core] {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_from_header_preserves_contract_fields() {
|
||||||
|
let h = VideoFrameHeader {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
ts_ms: 42,
|
||||||
|
seq: 7,
|
||||||
|
keyframe: true,
|
||||||
|
cursor: Some((640, 480)),
|
||||||
|
};
|
||||||
|
let payload = vec![0u8; 16];
|
||||||
|
let f: Frame = (h.clone(), payload.clone()).into();
|
||||||
|
assert_eq!((f.width, f.height), (1920, 1080));
|
||||||
|
assert_eq!(f.seq, 7);
|
||||||
|
assert!(f.keyframe);
|
||||||
|
// v5:光标字段必须透传,丢它=标记永远不显示,且丢法是静默的
|
||||||
|
assert_eq!(f.cursor, Some((640, 480)));
|
||||||
|
assert_eq!(f.payload, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_config_defaults_to_insecure_lan_mode() {
|
||||||
|
let c = ConnectConfig::direct("192.168.1.5", 47800, "abc");
|
||||||
|
assert!(c.insecure_skip_verify);
|
||||||
|
match c.target {
|
||||||
|
PeerTarget::Direct { host, port } => {
|
||||||
|
assert_eq!(host, "192.168.1.5");
|
||||||
|
assert_eq!(port, 47800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn strict_verify_mode_is_explicit_unimplemented_error() {
|
||||||
|
// 安全红线不能静默:显式要求校验时必须是明确错误,而不是悄悄放行。
|
||||||
|
// 不用 unwrap_err():它要求 Ok 类型(Session)实现 Debug,而连接对象
|
||||||
|
// 的 Debug 输出没有验证价值。
|
||||||
|
let cfg = ConnectConfig {
|
||||||
|
target: PeerTarget::Direct {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: 1,
|
||||||
|
},
|
||||||
|
code: "x".into(),
|
||||||
|
insecure_skip_verify: false,
|
||||||
|
};
|
||||||
|
match Session::connect(cfg).await {
|
||||||
|
Err(e) => {
|
||||||
|
assert_eq!(e.kind, ConnectErrorKind::Other);
|
||||||
|
assert!(e.detail.contains("证书钉扎"), "实际错误: {e}");
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("insecure_skip_verify=false 必须明确报错,不得静默放行"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_fail_classification_uses_agent_message_prefix() {
|
||||||
|
// 判据来自 agent 侧的真实文案(agent/src/main.rs:111),不是自造样本
|
||||||
|
assert_eq!(
|
||||||
|
classify_auth_fail("protocol version mismatch: client=2, server=3"),
|
||||||
|
ConnectErrorKind::Version
|
||||||
|
);
|
||||||
|
assert_eq!(classify_auth_fail("bad code"), ConnectErrorKind::Auth);
|
||||||
|
// 未知形态宁可归 Auth 也不静默
|
||||||
|
assert_eq!(
|
||||||
|
classify_auth_fail("expected Auth, got Ping(0)"),
|
||||||
|
ConnectErrorKind::Auth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ffi_error_codes_are_stable_and_unique() {
|
||||||
|
// FFI 编号是 Kotlin 映射表的契约面:改动=公共 API 变更。
|
||||||
|
// 期望值写死(R-8:判据是等于期望值,不是"没重复")。
|
||||||
|
let expect = [
|
||||||
|
(ConnectErrorKind::Resolve, 1),
|
||||||
|
(ConnectErrorKind::Timeout, 2),
|
||||||
|
(ConnectErrorKind::Refused, 3),
|
||||||
|
(ConnectErrorKind::Auth, 4),
|
||||||
|
(ConnectErrorKind::Version, 5),
|
||||||
|
(ConnectErrorKind::Other, 9),
|
||||||
|
];
|
||||||
|
for (kind, code) in expect {
|
||||||
|
assert_eq!(kind.code(), code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unresolvable_host_is_classified_as_resolve() {
|
||||||
|
// 本机环境事实(2026-10-31 实测):这台办公机的 DNS 拦截层(Meta/198.18.0.1)
|
||||||
|
// 把 `.invalid` 直接**解析成功**并指向 198.18.0.x 假地址——RFC 6761 的
|
||||||
|
// NXDOMAIN 保证在这条链路上不成立,`.invalid` 样本会走成 Timeout。
|
||||||
|
// 含空格的 name 在任何 resolver 里都是非法输入、快速失败(实测 26ms),
|
||||||
|
// 判据不依赖本机 DNS 行为。
|
||||||
|
let cfg = ConnectConfig::direct("a b", 47800, "t");
|
||||||
|
let e = match Session::connect(cfg).await {
|
||||||
|
Ok(_) => panic!("非法主机名必须解析失败"),
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
assert_eq!(e.kind, ConnectErrorKind::Resolve, "实际: {e:?}");
|
||||||
|
assert!(e.detail.contains("a b"), "detail 必须含原输入: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unreachable_peer_redials_then_reports_timeout() {
|
||||||
|
// TEST-NET-1(RFC 5737):无可达主机 → 每拨握手都无应答。
|
||||||
|
// 方案①(用户裁决):第一拨 5s 超时 → 重拨 → 第二拨 8s 超时 → 报 Timeout。
|
||||||
|
// 期望落点 [13s-1s, 14s+2s):
|
||||||
|
// - 下界 ≈ 两拨握手预算之和,证明**第二拨真的跑完**(重拨丢失就是回归);
|
||||||
|
// - 上界证明总预算封顶(不会滚向第三拨)。
|
||||||
|
// 修复前(quinn 默认)这里要 32s;U8 判据形态更新为「≤13s 且全程可取消」。
|
||||||
|
let cfg = ConnectConfig::direct("192.0.2.1", 47800, "t");
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let e = match Session::connect(cfg).await {
|
||||||
|
Ok(_) => panic!("TEST-NET-1 无主机,必须失败"),
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
let elapsed = t0.elapsed();
|
||||||
|
assert_eq!(e.kind, ConnectErrorKind::Timeout, "实际: {e:?}");
|
||||||
|
let two_dials = CONNECT_BUDGET + CONNECT_RETRY_BUDGET;
|
||||||
|
assert!(
|
||||||
|
elapsed >= two_dials - Duration::from_secs(1)
|
||||||
|
&& elapsed < CONNECT_TOTAL_BUDGET + Duration::from_secs(2),
|
||||||
|
"重拨后的等待 {elapsed:?} 不在 [{two_dials:?}-1s, {CONNECT_TOTAL_BUDGET:?}+2s)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn budget_constants_match_redial_design() {
|
||||||
|
// 方案①常量关系固化(2026-10-31 用户裁决):
|
||||||
|
// - 第一拨握手 5s:不可达的快速探测
|
||||||
|
// - 第二拨握手 8s:第二拨起点在 ~5s,需盖住实测僵尸释放点 ~10.2s
|
||||||
|
// (R-5 idle 超时)+ 余量——比第一拨长是这个物理原因,不是随手加大
|
||||||
|
// - 总预算 14s:两拨之和 + 1s,让内层各拨 Timeout 先触发(错误 detail
|
||||||
|
// 精确到段),外层只封顶失控组合
|
||||||
|
// - 认证 11s:> 僵尸释放 10.2s(实测),握手成功后排队不被压断
|
||||||
|
assert_eq!(CONNECT_BUDGET, Duration::from_secs(5));
|
||||||
|
assert_eq!(CONNECT_RETRY_BUDGET, Duration::from_secs(8));
|
||||||
|
assert_eq!(AUTH_BUDGET, Duration::from_secs(11));
|
||||||
|
assert_eq!(CONNECT_TOTAL_BUDGET, Duration::from_secs(14));
|
||||||
|
assert_eq!(MAX_CONNECT_ATTEMPTS, 2);
|
||||||
|
assert!(CONNECT_RETRY_BUDGET > CONNECT_BUDGET, "第二拨必须更长才盖得住释放点");
|
||||||
|
assert!(CONNECT_TOTAL_BUDGET > CONNECT_BUDGET + CONNECT_RETRY_BUDGET);
|
||||||
|
assert!(AUTH_BUDGET > Duration::from_secs(10));
|
||||||
|
// 重拨名单是策略面:只有暂时性失败可重试,改动必须过这条测试
|
||||||
|
assert!(is_retryable(ConnectErrorKind::Timeout));
|
||||||
|
assert!(is_retryable(ConnectErrorKind::Refused));
|
||||||
|
for k in [
|
||||||
|
ConnectErrorKind::Resolve,
|
||||||
|
ConnectErrorKind::Auth,
|
||||||
|
ConnectErrorKind::Version,
|
||||||
|
ConnectErrorKind::Other,
|
||||||
|
] {
|
||||||
|
assert!(!is_retryable(k), "{k:?} 是确定性失败,不得重拨");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_error_variants_map_to_declared_kinds() {
|
||||||
|
// 只测**可安全构造**的单元变体(unit variants):TransportError /
|
||||||
|
// ConnectionClosed / ApplicationClose 的字段是 `bytes::Bytes`,测试构造
|
||||||
|
// 需要把 bytes 提为直接依赖——为分类表测试引依赖不值当(偏差 D-R1)。
|
||||||
|
// 这三个结构变体的映射由 match 的穷尽性 + 上面的注释钉住;
|
||||||
|
// 真实归类靠 V-A2 的端到端(端口上跑非 QUIC 服务)覆盖。
|
||||||
|
assert_eq!(
|
||||||
|
classify_connection_error(&quinn::ConnectionError::TimedOut),
|
||||||
|
ConnectErrorKind::Timeout
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_connection_error(&quinn::ConnectionError::Reset),
|
||||||
|
ConnectErrorKind::Refused
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_connection_error(&quinn::ConnectionError::VersionMismatch),
|
||||||
|
ConnectErrorKind::Other
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_connection_error(&quinn::ConnectionError::LocallyClosed),
|
||||||
|
ConnectErrorKind::Other
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -110,6 +110,8 @@ struct Stats {
|
|||||||
agent_sent: AtomicU64,
|
agent_sent: AtomicU64,
|
||||||
/// H.264 解码失败计数(关键帧自愈验证指标)
|
/// H.264 解码失败计数(关键帧自愈验证指标)
|
||||||
dec_fail: AtomicU64,
|
dec_fail: AtomicU64,
|
||||||
|
/// 当前是否处于「参考链断裂、等待 IDR」状态(HUD 显示 RECOVER)
|
||||||
|
awaiting_idr: AtomicBool,
|
||||||
/// 帧到达间隔环形缓冲
|
/// 帧到达间隔环形缓冲
|
||||||
gaps: std::sync::Mutex<GapRing>,
|
gaps: std::sync::Mutex<GapRing>,
|
||||||
}
|
}
|
||||||
@ -129,6 +131,7 @@ impl Default for Stats {
|
|||||||
agent_lost: AtomicU64::new(0),
|
agent_lost: AtomicU64::new(0),
|
||||||
agent_sent: AtomicU64::new(0),
|
agent_sent: AtomicU64::new(0),
|
||||||
dec_fail: AtomicU64::new(0),
|
dec_fail: AtomicU64::new(0),
|
||||||
|
awaiting_idr: AtomicBool::new(true),
|
||||||
gaps: std::sync::Mutex::new(GapRing {
|
gaps: std::sync::Mutex::new(GapRing {
|
||||||
last: None,
|
last: None,
|
||||||
ring: [0; 64],
|
ring: [0; 64],
|
||||||
@ -218,9 +221,27 @@ async fn run(addr: String, port: u16, code: String) -> Result<()> {
|
|||||||
|
|
||||||
let (mut send, mut recv) = conn.open_bi().await?;
|
let (mut send, mut recv) = conn.open_bi().await?;
|
||||||
|
|
||||||
write_control(&mut send, &ControlMsg::Auth { code }).await?;
|
write_control(
|
||||||
|
&mut send,
|
||||||
|
&ControlMsg::Auth {
|
||||||
|
code,
|
||||||
|
proto_version: common::PROTO_VERSION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
match read_control(&mut recv).await? {
|
match read_control(&mut recv).await? {
|
||||||
ControlMsg::AuthOk { width, height } => {
|
ControlMsg::AuthOk {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
proto_version,
|
||||||
|
} => {
|
||||||
|
// 版本已在 Auth 里声明,这里再核对一次:防止被控端是旧版却"接受"了认证
|
||||||
|
if proto_version != common::PROTO_VERSION {
|
||||||
|
bail!(
|
||||||
|
"protocol version mismatch: server={proto_version}, client={}",
|
||||||
|
common::PROTO_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
println!("[viewer] auth ok, remote desktop {width}x{height}");
|
println!("[viewer] auth ok, remote desktop {width}x{height}");
|
||||||
// 流 2:键鼠输入 + Ping/Pong 独立通道,不与视频流竞争
|
// 流 2:键鼠输入 + Ping/Pong 独立通道,不与视频流竞争
|
||||||
let (isend, irecv) = conn.open_bi().await?;
|
let (isend, irecv) = conn.open_bi().await?;
|
||||||
@ -257,6 +278,15 @@ fn session(
|
|||||||
let mut last_kfreq = Instant::now();
|
let mut last_kfreq = Instant::now();
|
||||||
let mut skip_stale = 0u64;
|
let mut skip_stale = 0u64;
|
||||||
let mut last_arr: Option<Instant> = None;
|
let mut last_arr: Option<Instant> = None;
|
||||||
|
// 「等待 IDR」状态:参考链一旦断裂(跳帧或解码失败),后续 P 帧**必然**
|
||||||
|
// 解不出来。此时必须停止喂解码器,只等 IDR 重新同步。
|
||||||
|
//
|
||||||
|
// 之前没有这个状态,后果是:跳掉一帧后,每个 P 帧都被送进 decoder →
|
||||||
|
// 每个都失败 → 每个都打日志 → 每个都触发一次 KeyFrameReq。
|
||||||
|
// 表现为「decode failed」刷屏,且解码器反复在错误状态里挣扎。
|
||||||
|
//
|
||||||
|
// 放在 Stats 里(而非局部变量)以便 HUD 显示 RECOVER 状态。
|
||||||
|
// 初始 true:入会时已发 KeyFrameReq,拿到第一个 IDR 前不该硬解。
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut size_logged = false;
|
let mut size_logged = false;
|
||||||
loop {
|
loop {
|
||||||
@ -304,7 +334,10 @@ fn session(
|
|||||||
"[viewer] backlog: stale burst frame skipped (total {skip_stale})"
|
"[viewer] backlog: stale burst frame skipped (total {skip_stale})"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 限频请求关键帧:积压排空后从 IDR 重新衔接画面
|
// 跳帧直接切断 H.264 参考链:之后所有 P 帧都解不出来。
|
||||||
|
// 立刻转入「等待 IDR」并请求关键帧,绝不能让后续 P 帧
|
||||||
|
// 继续喂给解码器(那是必然失败的徒劳尝试)。
|
||||||
|
stats.awaiting_idr.store(true, Ordering::Relaxed);
|
||||||
if last_kfreq.elapsed() >= Duration::from_millis(200) {
|
if last_kfreq.elapsed() >= Duration::from_millis(200) {
|
||||||
last_kfreq = Instant::now();
|
last_kfreq = Instant::now();
|
||||||
if let Ok(mut slot) = latest.lock() {
|
if let Ok(mut slot) = latest.lock() {
|
||||||
@ -313,6 +346,22 @@ fn session(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 参考链未恢复时,P 帧一律跳过——此为正确性要求,不是优化。
|
||||||
|
if stats.awaiting_idr.load(Ordering::Relaxed) && !hdr.keyframe {
|
||||||
|
skip_stale += 1;
|
||||||
|
// 补发请求,防止首次 KeyFrameReq 丢失(时间闸限频)
|
||||||
|
if last_kfreq.elapsed() >= Duration::from_millis(200) {
|
||||||
|
last_kfreq = Instant::now();
|
||||||
|
if let Ok(mut slot) = latest.lock() {
|
||||||
|
*slot = Some((0, 0, Vec::new()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 拿到 IDR 才能重新开始解码
|
||||||
|
if hdr.keyframe {
|
||||||
|
stats.awaiting_idr.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
match decoder.decode(&payload) {
|
match decoder.decode(&payload) {
|
||||||
Ok(Some(yuv)) => {
|
Ok(Some(yuv)) => {
|
||||||
let (dw, dh) = yuv.dimensions();
|
let (dw, dh) = yuv.dimensions();
|
||||||
@ -334,7 +383,8 @@ fn session(
|
|||||||
}
|
}
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// 解码异常:请求被控端强制关键帧
|
// 解码异常:参考链已不可信,转入等待 IDR
|
||||||
|
stats.awaiting_idr.store(true, Ordering::Relaxed);
|
||||||
stats.dec_fail.fetch_add(1, Ordering::Relaxed);
|
stats.dec_fail.fetch_add(1, Ordering::Relaxed);
|
||||||
eprintln!("[viewer] decode failed: {e}, request keyframe");
|
eprintln!("[viewer] decode failed: {e}, request keyframe");
|
||||||
if let Ok(mut slot) = latest.lock() {
|
if let Ok(mut slot) = latest.lock() {
|
||||||
@ -577,6 +627,21 @@ fn session(
|
|||||||
("P95", &hud_p95.map(|p| format!("{p}ms")).unwrap_or("--".into()), p95_c),
|
("P95", &hud_p95.map(|p| format!("{p}ms")).unwrap_or("--".into()), p95_c),
|
||||||
("LOSS", &loss_str, loss_c),
|
("LOSS", &loss_str, loss_c),
|
||||||
("DECFAIL", &format!("{hud_dec}"), if hud_dec == 0 { C_GRAY } else { C_RED }),
|
("DECFAIL", &format!("{hud_dec}"), if hud_dec == 0 { C_GRAY } else { C_RED }),
|
||||||
|
(
|
||||||
|
// 参考链断裂、正在等 IDR 的显式指示:此时画面冻结是**预期行为**,
|
||||||
|
// 不是故障。没有这个指示,用户会以为软件卡死。
|
||||||
|
"STATE",
|
||||||
|
&if stats.awaiting_idr.load(Ordering::Relaxed) {
|
||||||
|
"RECOVER".to_string()
|
||||||
|
} else {
|
||||||
|
"OK".to_string()
|
||||||
|
},
|
||||||
|
if stats.awaiting_idr.load(Ordering::Relaxed) {
|
||||||
|
C_AMBER
|
||||||
|
} else {
|
||||||
|
C_GREEN
|
||||||
|
},
|
||||||
|
),
|
||||||
("RATE", &format!("{:.0}MBP", hud_mbps), C_GRAY),
|
("RATE", &format!("{:.0}MBP", hud_mbps), C_GRAY),
|
||||||
("RES", &format!("{w}x{h}"), C_GRAY),
|
("RES", &format!("{w}x{h}"), C_GRAY),
|
||||||
],
|
],
|
||||||
|
|||||||
301
借鉴映射与落地规范.md
Normal file
301
借鉴映射与落地规范.md
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
# 借鉴映射与落地规范
|
||||||
|
|
||||||
|
> 配套《多平台架构设计.md》。本文回答三个问题:**每个模块怎么设计、看谁、抄什么、不抄什么。**
|
||||||
|
> 状态:架构部分已定稿;第 2 章的「逆向需求清单」等同事的逆向资料回填。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 使用方式
|
||||||
|
|
||||||
|
| 角色 | 看哪几章 |
|
||||||
|
|---|---|
|
||||||
|
| A(被控侧) | §3.5 platform、§3.6 agent-core、§4 表中 agent 相关行 |
|
||||||
|
| B(主控+协议) | §3.1–3.4、§3.7 viewer-core、§5 许可 |
|
||||||
|
| 移动端(待补人) | §3.4 media(解码后端)、§3.8 apps |
|
||||||
|
| 全员 | §2 逆向红线(踩了会出大事) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 两条铁律
|
||||||
|
|
||||||
|
1. **架构由需求决定,不由参考项目决定。** 参考项目用来解决"这一块业界怎么做的",不是用来决定"我们做成什么样"。
|
||||||
|
2. **能抄的是算法和协议思路,抄不到的是平台集成和 UI。** 后者是体力活,任何参考项目都救不了(它们自己也是硬写的)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 逆向资料的用法与红线
|
||||||
|
|
||||||
|
### 2.1 红线(务必转告同事)
|
||||||
|
|
||||||
|
| 可以做 | 不可以做 |
|
||||||
|
|---|---|
|
||||||
|
| 记录**功能清单、交互流程、界面布局** | 把反编译产物、反汇编清单提交进仓库 |
|
||||||
|
| 记录**用户可见的错误提示、状态显示** | 逐字复制对方的 UI 文案、图标、素材 |
|
||||||
|
| 推断**产品模型**(账号/设备/授权怎么设计) | 抄对方私有协议的具体编码(抄了也不能互通) |
|
||||||
|
| 记录**网络行为的宏观特征**(是否中转、连接耗时) | 提取并移植对方的算法实现 |
|
||||||
|
|
||||||
|
**仓库里只提交"行为结论",不提交"逆向产物"。** 建议逆向资料单独放一个**不纳入 git** 的目录(或内部网盘),文档里只写结论。这条不是洁癖——商用产品里出现竞品反编译代码是实打实的法律风险。
|
||||||
|
|
||||||
|
### 2.2 逆向需求清单(直接发给同事)
|
||||||
|
|
||||||
|
**A. 产品功能面(最重要,优先做)**
|
||||||
|
|
||||||
|
1. 主控端完整交互流程截图流:登录 → 设备列表 → 连接(输密码/免密)→ 会话中 → 断开
|
||||||
|
2. 每个平台的功能矩阵:Win/Linux/Android/iOS 各有哪些功能、哪些缺失
|
||||||
|
3. 会话中工具栏全清单:分辨率切换、多屏、文件传输、剪贴板、音频开关、画质/帧率档位、游戏模式
|
||||||
|
4. 设置项全清单(分"被控设置"和"主控设置"两类)
|
||||||
|
5. 错误提示文案全集 + 断线重连时的用户可见行为
|
||||||
|
|
||||||
|
**B. 身份与授权模型**
|
||||||
|
|
||||||
|
6. 设备 ID 的形式(长度/字符集)、在哪里查看、能否自定义
|
||||||
|
7. 授权方式有几种:临时密码 / 固定密码 / 免密白名单 / 账号直接授权?默认是哪种
|
||||||
|
8. 账号体系:是否必须登录、登录后设备列表怎么呈现、一台设备能否被多账号访问
|
||||||
|
9. 被控端的"允许谁连"控制(黑名单/白名单/单次确认弹窗)
|
||||||
|
|
||||||
|
**C. 被控端形态**
|
||||||
|
|
||||||
|
10. 是不是系统服务?开机自启吗?锁屏/注销/UAC 提权界面能不能连?
|
||||||
|
11. 安装包形态、是否需要管理员权限、卸载残留
|
||||||
|
|
||||||
|
**D. 网络行为(只看宏观,不要深入协议)**
|
||||||
|
|
||||||
|
12. 连接时用户是否能看到"直连 / 中转"标识
|
||||||
|
13. 典型连接耗时、转中继时的画质变化
|
||||||
|
14. 端口/协议宏观特征:UDP 还是 TCP 为主、监听端口是否固定
|
||||||
|
|
||||||
|
**E. 商业化(可选但有用)**
|
||||||
|
|
||||||
|
15. 免费额度/收费点/增值功能边界
|
||||||
|
|
||||||
|
### 2.3 逆向资料回填位置
|
||||||
|
|
||||||
|
| 资料 | 回填到 |
|
||||||
|
|---|---|
|
||||||
|
| A 组 | 本文 §6「产品范围基线」+ 里程碑取舍 |
|
||||||
|
| B/C 组 | §3.3 session(身份模型影响状态机) |
|
||||||
|
| D 组 | §3.2 conn(验证我们的打洞/中继策略) |
|
||||||
|
| E 组 | 不在本文,属于商业决策 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 模块级设计规范
|
||||||
|
|
||||||
|
### 3.1 `proto` — 协议
|
||||||
|
|
||||||
|
**职责**:信令消息、控制消息、媒体容器格式。三端(agent/viewer/信令服务)共享契约。
|
||||||
|
|
||||||
|
**设计点**
|
||||||
|
- **双协议分离**:信令协议(`signaling.rs`,文本友好、低频、可 JSON)与媒体控制协议(`lib.rs`,二进制、高频)**不要混**。前者要便于抓包排查,后者要极致紧凑。
|
||||||
|
- **版本协商**:`SIGNALING_VERSION` 不一致直接拒绝,不做兼容。媒体协议用"能力协商"而非版本号(见 §3.3)。
|
||||||
|
- **消息演进**:现用 bincode。二期若换 protobuf,**必须一次性切换**,不要两套并存。
|
||||||
|
- **单帧长度上限**:现有 `ensure!(len < 1<<20)` 这类边界检查要保留,且信令侧对端候选列表必须经 `sanitize_candidates`。
|
||||||
|
|
||||||
|
**看谁 / 抄什么**
|
||||||
|
|
||||||
|
| 项目 | 抄什么 | 不抄 |
|
||||||
|
|---|---|---|
|
||||||
|
| RustDesk `libs/hbb_common` | 消息分类方式(信令/中继/控制分层) | 具体协议编码(AGPL + 抄了不互通) |
|
||||||
|
| Moonlight `moonlight-common-c` | **控制消息与媒体分离**的成熟做法 | 它的私有控制协议 |
|
||||||
|
|
||||||
|
**产出物**:`crates/proto/src/{lib.rs, signaling.rs}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 `conn` — 连接层(最值得借鉴的一块)
|
||||||
|
|
||||||
|
**职责**:信令客户端、打洞编排、路径选择、QUIC 会话、保活。
|
||||||
|
|
||||||
|
**设计点**
|
||||||
|
- **`PathProvider` 抽象**(已在《打洞与信令设计.md》定义):现在只实现 Direct,类型上预留 Relay。
|
||||||
|
- **打洞编排**:候选收集(v6/Host/UPnP/Reflexive/Predicted)→ 排序 → 信令交换 → 同步打洞 → 落选清理。
|
||||||
|
- **同一 socket 双角色**:打洞用的 UDP socket 必须与后续 QUIC 是同一个,否则洞不在同一映射上。
|
||||||
|
- **保活**:按双方 `binding_timeout_s` 折半,5–60s。**这是"连一会儿就断"的根治点。**
|
||||||
|
- **不用 QUIC 握手当探测**:先用轻量 UDP 探测包确认通路(秒级失败),再 `connect`。
|
||||||
|
|
||||||
|
**看谁 / 抄什么**
|
||||||
|
|
||||||
|
| 项目 | 抄什么 | 许可 |
|
||||||
|
|---|---|---|
|
||||||
|
| **iroh** | **整套**:打洞编排、中继回退、地址发现。技术栈与你们重合(Rust + quinn) | Apache-2.0 ✅ **可直接依赖** |
|
||||||
|
| webrtc-rs / pion/ice | ICE 状态机、候选配对(candidate pairing)策略 | MIT/Apache ✅ |
|
||||||
|
| Tailscale / headscale | DERP 中继的**回退判定条件**与降级策略 | BSD-3 ✅ |
|
||||||
|
| RustDesk `rendezvous_mediator` 相关 | 信令 ↔ 打洞的**编排顺序**(读思路) | AGPL ⚠️ 只读 |
|
||||||
|
| libjuice / libnice | ICE 精简实现(作为对照,不必依赖) | MPL / LGPL |
|
||||||
|
|
||||||
|
> **建议**:`conn` 优先评估**直接依赖 iroh**。它解决的正是你们纠结的"打洞要不要中继",而且给了"直连优先、失败回退"的完整实现。自研仅当 iroh 有硬伤(体积、可控性、特定 NAT 场景)时才考虑。
|
||||||
|
|
||||||
|
**产出物**:`crates/conn/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 `session` — 会话编排("一体程序"的核心)
|
||||||
|
|
||||||
|
**职责**:会话状态机、角色仲裁、能力协商、生命周期管理、被控端准入控制。
|
||||||
|
|
||||||
|
**设计点**
|
||||||
|
- **状态机双线**:主控线 / 被控线各自独立,同一进程可并发(被控中也能主控别人)。
|
||||||
|
- **角色仲裁**:打洞阶段双方对称,**谁先打通谁定角色**(被控方做 QUIC server),不预设。
|
||||||
|
- **能力协商**:被控端上报硬件能力(可用编码器、最大分辨率、帧率上限、是否支持音频),主控端选择。**用能力协商代替版本号**——旧客户端缺字段就是"不支持",天然向后兼容。
|
||||||
|
- **准入控制**:单会话/多会话、白名单、单次确认弹窗。**这一条直接来自 §2 逆向需求 B 组第 7/9 项**,等资料回填后定稿。
|
||||||
|
- **失败出口**:`PunchFailReason::user_hint()` 必须一路透传到 UI,不允许出现无解释的"连接中"。
|
||||||
|
|
||||||
|
**看谁 / 抄什么**
|
||||||
|
|
||||||
|
| 项目 | 抄什么 |
|
||||||
|
|---|---|
|
||||||
|
| RustDesk `src/server` / `src/client` | 会话生命周期与连接管理的分层方式(读思路) |
|
||||||
|
| Sunshine + Moonlight | host/client 两侧的**能力协商**与配置下发流程 |
|
||||||
|
| — | 状态机本身建议自己画,不要套用别人的(各家产品逻辑差异大) |
|
||||||
|
|
||||||
|
**产出物**:`crates/session/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 `media` — 编解码抽象
|
||||||
|
|
||||||
|
**职责**:编码/解码 trait、各平台后端、零拷贝通路、码率自适应闭环。
|
||||||
|
|
||||||
|
**设计点**
|
||||||
|
- **不对称策略**:**编码用 FFmpeg**(只在 Win/Linux,桌面端不心疼包体),**解码用平台原生**(四个主控平台都要,移动端必须硬解且要零拷贝)。
|
||||||
|
- **trait 收口**(见《多平台架构设计.md》§4 决策 4),上层不允许出现平台判断。
|
||||||
|
- **零拷贝**:解码器直接输出到渲染表面(D3D11 纹理 / `SurfaceView` / `CAMetalLayer`),中间不做 CPU 拷贝。
|
||||||
|
- **软硬兜底**:硬编失败回落 openh264;硬解失败回落软解(桌面可,移动端应直接提示而非硬撑)。
|
||||||
|
- **码率自适应闭环**:接收端统计(丢包/抖动/帧间隔 p95)→ 反馈编码端 → AIMD 调码率。协议已留 `RateReport`(见 B侧设计文档 B2)。
|
||||||
|
|
||||||
|
**看谁 / 抄什么**
|
||||||
|
|
||||||
|
| 项目 | 抄什么 | 许可 |
|
||||||
|
|---|---|---|
|
||||||
|
| **libwebrtc** `modules/congestion_controller/goog_cc` | **GCC 拥塞控制**的完整思路——这是码率自适应的教科书 | BSD-3 ✅ 可参考可移植 |
|
||||||
|
| **libwebrtc** `modules/video_coding/jitter_buffer` | 抖动缓冲与丢包重排 | BSD-3 ✅ |
|
||||||
|
| **Sunshine** `src/video` + `src/platform` | **抓屏→编码→打包**的完整管线组织,尤其是多编码器抽象与回落 | GPL-3.0 ⚠️ 只读 |
|
||||||
|
| **Moonlight** `moonlight-common-c` | 接收端解码→渲染→HUD 的管线,以及**丢包恢复策略** | GPL-3.0 ⚠️ 只读 |
|
||||||
|
| FFmpeg | NVENC/QSV/AMF/VAAPI 的调用姿势与参数 | LGPL/GPL ⚠️ 注意构建选项 |
|
||||||
|
| openh264 | 软编兜底(已在用) | BSD ✅ |
|
||||||
|
|
||||||
|
**产出物**:`crates/media/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 `platform` — L0 平台层(抄不到,只能自己写)
|
||||||
|
|
||||||
|
**职责**:抓屏、注入、渲染表面、音频 IO、权限、常驻。
|
||||||
|
|
||||||
|
**为什么抄不到**:这层是各平台 API 的体力活。参考项目能给的是"用哪个 API、踩过什么坑",不是可复用代码。
|
||||||
|
|
||||||
|
**设计点**
|
||||||
|
|
||||||
|
| 平台 | 关键坑 |
|
||||||
|
|---|---|
|
||||||
|
| Win 抓屏 | DXGI DD 在**会话切换/RDP 断开**时失效,必须处理 `DXGI_ERROR_ACCESS_LOST` 并重建 |
|
||||||
|
| Win 注入 | 服务进程注入到用户会话需要 `CreateProcessAsUser` + 会话隔离处理;UAC 提权界面默认收不到输入(需 `uiAccess` 签名) |
|
||||||
|
| Win 常驻 | 服务 + 用户会话代理双进程,锁屏可连靠的是**服务侧保持会话** |
|
||||||
|
| Linux 抓屏 | X11 用 XShm/XComposite;**Wayland 必须走 PipeWire + xdg-desktop-portal**(有授权弹窗,无法静默) |
|
||||||
|
| Linux 注入 | X11 用 XTest;**Wayland 用 libei/uinput**(uinput 需 root 或 udev 规则) |
|
||||||
|
| Linux 常驻 | systemd 服务 + 会话切换处理(Wayland 的 portal 授权是最大障碍) |
|
||||||
|
| 渲染表面 | 四平台各一套;**必须支持"表面尺寸≠视频尺寸"的缩放与拉伸模式** |
|
||||||
|
|
||||||
|
**看谁**
|
||||||
|
|
||||||
|
| 项目 | 看什么 |
|
||||||
|
|---|---|
|
||||||
|
| Sunshine `src/platform/{windows,linux}` | 抓屏与注入的分平台组织方式、Wayland 处理 |
|
||||||
|
| RustDesk `libs/scrap` | 同样的抓屏抽象(多平台统一 trait)——**这是它最有价值的部分** |
|
||||||
|
| libei / xdg-desktop-portal 官方文档 | Wayland 的正规姿势(别用野路子) |
|
||||||
|
|
||||||
|
**产出物**:`crates/platform/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 `agent-core` / `viewer-core` — 两条链路
|
||||||
|
|
||||||
|
**职责**:把现有 `agent`/`viewer` 的 main 逻辑抽成库,供一体程序按角色调用。
|
||||||
|
|
||||||
|
**设计点**
|
||||||
|
- **`agent-core`**:capture → encode → 打包 → 发送;输入接收 → 注入。A 已在进行中(`agent-core/src/lib.rs` 398 行)。
|
||||||
|
- **`viewer-core`**:接收 → 解码 → 渲染表面;输入采集 → 发送。
|
||||||
|
- **生命周期**:两者都不允许自己决定退出,由 `session` 层驱动。
|
||||||
|
- **单槽 latest-wins**:现有做法保留(网络慢时只发最新帧),这是正确设计。
|
||||||
|
|
||||||
|
**看谁**:RustDesk(抽库方式)、Sunshine/Moonlight(管线组织)。
|
||||||
|
|
||||||
|
**产出物**:`crates/agent-core/`、`crates/viewer-core/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.7 `apps/*` — UI 与常驻(第二个抄不到的地方)
|
||||||
|
|
||||||
|
**职责**:UI 壳、常驻服务、安装包。
|
||||||
|
|
||||||
|
**设计点**
|
||||||
|
- **硬约束**:所有业务逻辑在 Rust,UI 只调一层稳定 API(决定了将来能否换 UI 框架)。
|
||||||
|
- **视频走原生子表面**,绝不在 WebView 里逐帧渲染。
|
||||||
|
- **一体 = 一个安装包 + 两个进程**:前台 UI + 后台被控服务,本地 IPC。
|
||||||
|
- **移动端只有前台进程**(纯主控)。
|
||||||
|
|
||||||
|
**看谁**
|
||||||
|
|
||||||
|
| 项目 | 抄什么 |
|
||||||
|
|---|---|
|
||||||
|
| Tauri v2 官方 mobile 示例 | WKWebView/Android WebView 与原生 surface 的叠加方式 |
|
||||||
|
| RustDesk `flutter/` | 同一套 UI 适配多端的组织方式(它用 Flutter,可对照取舍) |
|
||||||
|
| Moonlight `moonlight-android` / `moonlight-ios` | **移动端主控的完整交互**:虚拟鼠标、手势映射、软键盘、性能档位 |
|
||||||
|
|
||||||
|
**产出物**:`apps/desktop/`、`apps/android/`、`apps/ios/`、`apps/daemon-win/`、`apps/daemon-linux/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 借鉴映射总表
|
||||||
|
|
||||||
|
| 模块 | 主参考 | 许可 | 用法 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 打洞 / 中继 / 地址发现 | **iroh** | Apache-2.0 | **直接依赖** |
|
||||||
|
| ICE / STUN / TURN | webrtc-rs、pion、coturn | MIT/BSD | 依赖或参考 |
|
||||||
|
| 中继回退策略 | Tailscale / headscale | BSD-3 | 读思路 |
|
||||||
|
| 拥塞控制 / 抖动缓冲 | **libwebrtc** | BSD-3 | 读思路,重写 |
|
||||||
|
| 抓屏→编码管线 | **Sunshine** | GPL-3.0 | 只读 |
|
||||||
|
| 接收端解码→渲染 | **Moonlight** | GPL-3.0 | 只读 |
|
||||||
|
| 整机架构 / 抽库方式 | **RustDesk** | **AGPL-3.0** | **只读,绝不抄代码** |
|
||||||
|
| 移动端主控交互 | Moonlight mobile | GPL-3.0 | 只读 |
|
||||||
|
| Rust↔移动端绑定 | UniFFI | MPL-2.0 | 依赖 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 许可结论与依赖策略
|
||||||
|
|
||||||
|
| 许可 | 能否进产品 | 项目 |
|
||||||
|
|---|---|---|
|
||||||
|
| MIT / Apache-2.0 / BSD | ✅ 可依赖、可移植 | iroh、webrtc-rs、pion、coturn、libwebrtc、UniFFI(MPL 弱) |
|
||||||
|
| LGPL | ⚠️ 可动态链接,改库要开源 | FFmpeg、GStreamer、libnice |
|
||||||
|
| **GPL / AGPL** | ❌ **不可进代码** | **RustDesk(AGPL)、Sunshine/Moonlight(GPL)** |
|
||||||
|
|
||||||
|
**AGPL 特别提示**:网络服务也算分发。引入 RustDesk 代码 = 你们整个产品连服务端都要开源。
|
||||||
|
|
||||||
|
**依赖准入流程**(补充进《Git协作规范.md》的合并规则):新增依赖必须在 PR 描述里写明**许可证 + 是否修改其源码**,由另一人确认。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 产品范围基线(待逆向资料回填)
|
||||||
|
|
||||||
|
> 拿到 §2.2 的 A 组资料后填这里,用于定"做哪些功能、砍哪些平台"。
|
||||||
|
|
||||||
|
| 功能 | UU 有 | 我们一期 | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 桌面画面 | | ✅ | 已有 |
|
||||||
|
| 键鼠控制 | | ✅ | 已有 |
|
||||||
|
| 音频 | | ? | |
|
||||||
|
| 文件传输 | | ? | |
|
||||||
|
| 剪贴板同步 | | ? | |
|
||||||
|
| 多屏 | | ? | |
|
||||||
|
| 移动端主控 | | ? | 人力问题见《多平台架构设计.md》§8 |
|
||||||
|
| 无人值守 | | ? | 安全含义,需固定密码+白名单 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 待拍板
|
||||||
|
|
||||||
|
1. `conn` 是否**直接依赖 iroh**(而不是自研打洞)——建议先花 2 天做技术验证
|
||||||
|
2. 逆向资料回来后,"产品范围基线"的取舍(尤其移动端)
|
||||||
|
3. `media` 编码侧是否引入 FFmpeg(包体/构建成本 vs 省下的适配工作)
|
||||||
|
4. 依赖准入流程是否写进《Git协作规范.md》
|
||||||
315
多平台架构设计.md
Normal file
315
多平台架构设计.md
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
# 多平台架构设计
|
||||||
|
|
||||||
|
> 需求:**主控端** Windows / Linux / Android / iOS;**被控端** Windows / Linux。
|
||||||
|
> 桌面端(Win/Linux)主控+被控合为一体;移动端仅主控。
|
||||||
|
> 前置文档:《打洞与信令设计.md》《B侧设计文档.md》《构建运行手册.md》
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 一句话架构
|
||||||
|
|
||||||
|
**一个 Rust 核心 + 一层薄平台适配 + 可替换的 UI 壳。**
|
||||||
|
业务逻辑(连接/打洞/会话/协议)100% 在 Rust 里跨平台共享;只有**抓屏、编解码、注入、渲染表面**四件事分平台写;UI 层只管"壳",**永远不碰视频流**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 纵向分层
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ L4 UI 壳 Win/Linux/Android/iOS 各自实现 │
|
||||||
|
│ 设备列表 / 设置 / 连接管理 / 悬浮控件 │
|
||||||
|
│ ★ 绝不放视频渲染 │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ L3 会话层 session 纯 Rust,全平台共享 │
|
||||||
|
│ 会话状态机、角色仲裁、能力协商、keepalive 调度 │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ L2 连接层 conn 纯 Rust,全平台共享 │
|
||||||
|
│ 信令客户端、打洞编排、中继接口(预留)、QUIC(quinn)多流 │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ L1 媒体层 media Rust + 平台后端 │
|
||||||
|
│ 编码/解码/色彩转换/缩放/音频(Opus) trait 化 │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ L0 平台层 platform 唯一必须分平台的地方 │
|
||||||
|
│ 抓屏 / 注入 / 音频IO / 渲染表面 / 权限 / 常驻 │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**分界线原则**:L1 以上任何代码不允许出现 `#[cfg(windows)]`。平台差异只能通过 L0 的 trait 暴露。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 仓库结构
|
||||||
|
|
||||||
|
```
|
||||||
|
remote/
|
||||||
|
├── crates/
|
||||||
|
│ ├── proto/ # 协议:信令 + 控制 + 媒体容器(现 common/)
|
||||||
|
│ ├── conn/ # 信令客户端 / 打洞编排 / QUIC 会话 / 中继接口
|
||||||
|
│ ├── session/ # 会话状态机 + 角色仲裁("一体"的核心)
|
||||||
|
│ ├── agent-core/ # 被控链路:capture → encode → send [A 已在做]
|
||||||
|
│ ├── viewer-core/ # 主控链路:recv → decode → surface
|
||||||
|
│ ├── media/ # 编解码 trait + 各平台后端 + openh264 软兜底
|
||||||
|
│ └── platform/ # L0 实现,按 cfg 编译各平台
|
||||||
|
├── apps/
|
||||||
|
│ ├── desktop/ # Tauri v2 壳:Win/Linux 一体程序
|
||||||
|
│ ├── android/ # 主控(Tauri mobile 或原生 Kotlin)
|
||||||
|
│ ├── ios/ # 主控(Tauri mobile 或原生 Swift)
|
||||||
|
│ ├── daemon-win/ # Windows 服务:被控常驻
|
||||||
|
│ └── daemon-linux/ # systemd:被控常驻
|
||||||
|
├── bindings/ # UniFFI 生成的 Kotlin/Swift 绑定
|
||||||
|
└── tools/ # bench / 信令服务器
|
||||||
|
```
|
||||||
|
|
||||||
|
`agent-core` 与 `viewer-core` 的拆分是**一体化的前提**:现在的 `agent`/`viewer` 是两个 main,必须先变成库,才能被同一个进程按角色调用。A 正在做的 `agent-core` 抽取就是在铺这条路。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 平台能力矩阵
|
||||||
|
|
||||||
|
| 能力 | Windows | Linux | Android | iOS |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 抓屏 | DXGI Desktop Duplication | X11 (XShm) / Wayland (PipeWire portal) | — | — |
|
||||||
|
| 硬编 | NVENC / QSV / AMF | VAAPI / NVENC | — | — |
|
||||||
|
| 硬解 | D3D11VA / NVDEC | VAAPI | MediaCodec | VideoToolbox |
|
||||||
|
| 渲染表面 | D3D11 子窗口 | X11/GL 子窗口 | SurfaceView | UIView + CAMetalLayer |
|
||||||
|
| 键鼠注入 | SendInput | XTest / uinput / libei | — | — |
|
||||||
|
| 键鼠采集 | Raw Input | evdev | 触摸→鼠标映射 | 触摸→鼠标映射 |
|
||||||
|
| 音频出 | WASAPI loopback | PulseAudio monitor | — | — |
|
||||||
|
| 音频入/放 | WASAPI | PulseAudio | AudioTrack | AVAudioEngine |
|
||||||
|
| 常驻服务 | Windows Service | systemd | — | — |
|
||||||
|
| **角色** | **主 + 被** | **主 + 被** | **仅主** | **仅主** |
|
||||||
|
|
||||||
|
移动端没有"被控",所以 Android/iOS **不需要**抓屏、编码、注入、服务化——少了一半工作量,这是需求里最省事的一条。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 六个关键决策
|
||||||
|
|
||||||
|
### 决策 1:视频渲染绝不走 UI 框架
|
||||||
|
|
||||||
|
从 WebView / Compose / Flutter 里逐帧渲染 60fps 视频必然掉帧、增延迟。
|
||||||
|
**做法**:UI 框架只画"壳",视频走**原生子表面**叠加在 UI 之上——
|
||||||
|
Win: 子 HWND + D3D11;Linux: 子窗口 + GL;Android: `SurfaceView`;iOS: `UIView` + `CAMetalLayer`。
|
||||||
|
MediaCodec / VideoToolbox 都能**直接解码到该 Surface,零拷贝**。这是 Parsec/ToDesk 这类产品的通行做法。
|
||||||
|
|
||||||
|
### 决策 2:UI 框架 —— 推荐 Tauri v2,但必须可替换
|
||||||
|
|
||||||
|
| 方案 | 覆盖 | 评价 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Tauri v2** | Win/Linux/Android/iOS | ✅ 推荐:Rust 原生、一套 UI 四端、IPC 天然 |
|
||||||
|
| Flutter | 四端 | UI 强,但 Rust 桥(flutter_rust_bridge)+ 平台视图叠加更绕 |
|
||||||
|
| 各平台原生 ×4 | 四端 | 性能最好,**2-3 人团队不可能维护** |
|
||||||
|
|
||||||
|
选 Tauri v2 的理由:你们本来就写 Rust,视频走原生表面后 WebView 只做表单和列表,性能足够。
|
||||||
|
**代价**:Tauri 移动端还不够成熟(WKWebView 与原生 surface 叠加、生命周期)。所以——
|
||||||
|
|
||||||
|
> **硬性约束:所有业务逻辑必须在 Rust 里,UI 只通过一层稳定 API 调用。**
|
||||||
|
> 这样移动端若体验不达标,可以单点退化成原生 Kotlin/Swift + UniFFI,桌面仍用 Tauri,核心零改动。
|
||||||
|
|
||||||
|
### 决策 3:Rust ↔ 移动端绑定用 UniFFI
|
||||||
|
|
||||||
|
6 个平台角色组合下手写 JNI/FFI 会失控。UniFFI 从 Rust 生成 Kotlin/Swift 绑定,含 async、错误类型、对象生命周期,是当前标准解法。
|
||||||
|
|
||||||
|
### 决策 4:编码用 FFmpeg,解码用平台原生
|
||||||
|
|
||||||
|
不对称,但都有理由:
|
||||||
|
|
||||||
|
- **编码(只在 Win/Linux 被控端)**:用 FFmpeg 统一接管 NVENC/QSV/AMF/VAAPI,openh264 软编兜底。桌面端不心疼包体,省下大量适配。
|
||||||
|
- **解码(四个主控平台都要)**:直接用平台原生 API。
|
||||||
|
- 移动端**必须**硬解(软解烧电池、发热、跑不动 1080p60)
|
||||||
|
- 平台原生能零拷贝直出渲染表面,FFmpeg 反而多一次拷贝
|
||||||
|
- 绕开 FFmpeg 在 Android/iOS 上的交叉编译地狱和许可问题
|
||||||
|
|
||||||
|
`media` crate 用 trait 收口:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait VideoDecoder {
|
||||||
|
fn configure(&mut self, cfg: &VideoConfig, target: SurfaceHandle) -> Result<()>;
|
||||||
|
fn decode(&mut self, packet: &[u8]) -> Result<Option<FrameHandle>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait VideoEncoder {
|
||||||
|
fn configure(&mut self, cfg: &EncodeConfig) -> Result<()>;
|
||||||
|
fn encode(&mut self, frame: &FrameHandle, out: &mut Vec<u8>) -> Result<()>;
|
||||||
|
fn request_keyframe(&mut self);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 决策 5:"一体程序" = 一个安装包 + 两个进程
|
||||||
|
|
||||||
|
用户视角是一个 app,实现上职责分离:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ 前台进程(UI) │ IPC │ 后台常驻(被控服务) │
|
||||||
|
│ 主控画面 / 设备列表 │<──────>│ 抓屏·编码·注入·保活 │
|
||||||
|
│ 退出即结束 │ 本地管道│ 开机自启 / 锁屏可连 │
|
||||||
|
└────────────────────────┘ └──────────────────────────┘
|
||||||
|
▲ ▲
|
||||||
|
│ 移动端只有这一个 │ 仅 Win/Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
理由:**两种角色的生命周期天然冲突**——被控要"开机就在、没人操作也在",主控要"跟窗口走、关了就走"。硬塞进一个进程会导致主控一退出就掉线。
|
||||||
|
IPC 用命名管道(Win)/ Unix socket(Linux),或复用 QUIC 走本地回环。
|
||||||
|
|
||||||
|
### 决策 6:中继接口必须预留 —— 哪怕现在不实现
|
||||||
|
|
||||||
|
`conn` 层定义:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait PathProvider {
|
||||||
|
/// 返回一条可用数据通道:直连优先,失败时可回退
|
||||||
|
async fn establish(&self, peer: &PeerInfo) -> Result<Path>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Path { Direct { addr: SocketAddr }, Relay { endpoint: EndpointId } }
|
||||||
|
```
|
||||||
|
|
||||||
|
现在只实现 `Direct`。**但类型上留出 `Relay`**,将来补中继不必动会话层。
|
||||||
|
|
||||||
|
> ⚠️ **这条与"不做中继"的决定直接相关,见 §6。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 会话状态机(L3 核心)
|
||||||
|
|
||||||
|
一体程序两条线:
|
||||||
|
|
||||||
|
```
|
||||||
|
主控线: Idle → Signalling → Punching → Handshaking → Streaming → Closing
|
||||||
|
被控线: Idle → Registered → Incoming → Punching → Handshaking → Serving → Closing
|
||||||
|
↑ 同一进程两线可并发(被控中也能主控别人)
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
- 打洞阶段**双方对称**:谁都不预设客户端/服务端,各自向对端候选发包,谁先打通谁定角色(被控方做 QUIC server)
|
||||||
|
- 会话能力协商:分辨率、编码器、码率上限、是否带音频——由**被控端**根据硬件能力给候选,主控端选
|
||||||
|
- 单进程内用 tokio 一runtime 跑多会话;被控同时只服务一个主控(可配置)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ✅ 已裁决:不做中继(2026-10-28)
|
||||||
|
|
||||||
|
> **裁决结论**:公司不提供中继服务器,中继不做。以下保留原始分析以免后人重蹈讨论。
|
||||||
|
|
||||||
|
**手机在蜂窝网络下几乎 100% 处于运营商级 NAT(CGNAT),打洞打不通。**
|
||||||
|
这不是实现问题,是网络事实——`100.64.0.0/10` 那段地址根本没给你可映射的公网端口。
|
||||||
|
|
||||||
|
后果:**"主控端支持安卓/iOS" + "不做中继" = 手机只能在 WiFi 下用。**
|
||||||
|
地铁上、外面、用流量,一律连不上。
|
||||||
|
|
||||||
|
三条出路:
|
||||||
|
|
||||||
|
| 方案 | 代价 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A. 只靠 IPv6** | 部分用户不可用 | 国内三大运营商 v6 覆盖率高,手机 v6 可用性远好于 v4 公网 |
|
||||||
|
| B. 补一个只为移动端服务的中继 | 视频带宽成本 | **已排除:公司不提供额外服务器** |
|
||||||
|
| C. 接受"手机只能 WiFi" | 产品力受限 | 零成本,但要写进产品说明 |
|
||||||
|
|
||||||
|
### 6.1 裁决内容与边界(2026-10-28)
|
||||||
|
|
||||||
|
| 项 | 裁决 |
|
||||||
|
|---|---|
|
||||||
|
| 中继 / TURN / relay 服务器 | **不做**。公司不提供额外服务器,且无预算计划 |
|
||||||
|
| IPv6 | **一等公民**。候选地址排序 v6 优先,`signaling.rs` 已实现 |
|
||||||
|
| 中继接口预留 | **不预留**。不写 `PathProvider` trait、不写 `Path::Relay` 变体 |
|
||||||
|
| 连接层边界 | **仍要做**。理由见 §6.2,与中继无关 |
|
||||||
|
|
||||||
|
**为什么不留中继接口**:留接口的收益是「将来补中继不用动会话层」,但既然中继已被排除,
|
||||||
|
`Path::Relay` 这类变体永远不会被构造,只会产生 `dead_code` 警告,要么 `#[allow]` 要么日后删除。
|
||||||
|
**零收益的预留就是纯负担。**
|
||||||
|
|
||||||
|
### 6.2 连接层边界与中继接口是两件事
|
||||||
|
|
||||||
|
这两件事常被混为一谈,必须分开:
|
||||||
|
|
||||||
|
| | 内容 | 现在做不做 |
|
||||||
|
|---|---|---|
|
||||||
|
| **连接层边界** | 把"怎么连"(信令/打洞/直连)与"连上干什么"(收流/解码/渲染)分开 | ✅ **要做** |
|
||||||
|
| **中继接口** | `enum Path { Direct, Relay }` 这类类型预留 | ❌ **不做** |
|
||||||
|
|
||||||
|
连接层边界本身就有独立价值,与中继无关:
|
||||||
|
|
||||||
|
1. **连接方式确定会变**:局域网 `IP:端口` → 将来设备码 + 打洞,这是已定的路线;
|
||||||
|
2. **安卓主控要复用**:`viewer-core` 收流解码渲染不应知道对面是怎么连上的;
|
||||||
|
3. **可测性**:解码逻辑能脱离真连接单独测试。
|
||||||
|
|
||||||
|
做法是把它抽成一个函数边界(输入目标描述、返回 `quinn::Connection`),
|
||||||
|
**不是**定义 trait 或枚举。
|
||||||
|
|
||||||
|
### 6.3 对产品的影响(必须写进对外说明)
|
||||||
|
|
||||||
|
| 网络环境 | 能否连接 |
|
||||||
|
|---|---|
|
||||||
|
| 双方任一侧无公网 IPv6,且处于 CGNAT 后 | ❌ **无法连接,无解** |
|
||||||
|
| 双方都有公网 IPv6 | ✅ 可直连 |
|
||||||
|
| 局域网 / 双方都有可打洞的公网 IPv4 | ✅ 可直连 |
|
||||||
|
| 对称 NAT(双方) | ❌ 通常不通 |
|
||||||
|
|
||||||
|
**这不是 bug,是已知边界。** 用户在外网连不上时,UI 必须给出可执行提示
|
||||||
|
(`PunchFailReason::user_hint()` 已提供文案),不能停留在"连接中"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 分期路线(不要一次做 6 个组合)
|
||||||
|
|
||||||
|
| 里程碑 | 内容 | 预估 | 依赖 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **M1** | Win↔Win 打通:信令 + 打洞 + 证书钉扎 | 2-3 周 | 协议已就绪 |
|
||||||
|
| **M2** | Win 一体化:agent-core 抽库、会话状态机、前台/后台双进程 | 2-3 周 | M1 |
|
||||||
|
| **M3** | Win 被控服务化(开机即在线、锁屏可连、UAC 处理) | 2 周 | M2 |
|
||||||
|
| **M4** | Linux 被控(X11 先行,Wayland/PipeWire 预留) | 2-3 周 | M2,A 在做 |
|
||||||
|
| **M5** | 桌面 UI(Tauri v2 壳 + 原生视频表面) | 3 周 | M2 |
|
||||||
|
| **M6** | **Android 主控**(MediaCodec + UniFFI + 触摸映射) | 3-4 周 | M5 |
|
||||||
|
| **M7** | **iOS 主控**(VideoToolbox + SwiftUI/UniFFI) | 3-4 周 | M6 |
|
||||||
|
| **M8** | Linux 主控(复用 M5 的桌面 UI) | 1 周 | M5 |
|
||||||
|
| 穿插 | 音频(Opus) / 文件传输 / 多屏 | 各 1-2 周 | — |
|
||||||
|
|
||||||
|
**先做 Android 再做 iOS**:不用苹果开发者账号、真机调试方便、热重载快、审核风险低。iOS 放最后。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. ⚠️ 必须说清楚的人力问题
|
||||||
|
|
||||||
|
现有团队 **2-3 人**,目前连"Win↔Win 打通"都还没做完。而这份需求是:
|
||||||
|
|
||||||
|
- 2 个被控平台(抓屏 + 编码 + 注入 + 服务化)× 各自两套显示服务器(Linux X11/Wayland)
|
||||||
|
- 4 个主控平台(解码 + 渲染 + 输入 + UI)× 各自一套原生集成
|
||||||
|
- 外加信令、打洞、音频、文件、多屏、账号体系
|
||||||
|
|
||||||
|
**这是 6-10 人年的工作量,2-3 人做不完。** 不是唱衰,是排期事实——Android + iOS 两个平台的 UI 和平台集成,单独拿出来就够一个人全职做半年。
|
||||||
|
|
||||||
|
可行的收缩方式(选一个):
|
||||||
|
|
||||||
|
1. **砍掉 iOS**(苹果审核 + 真机调试 + 开发者账号成本最高,收益最小)
|
||||||
|
2. **移动端只做 Android**,iOS 用 Web 端替代(但 Web 端无法硬解,体验差)
|
||||||
|
3. **桌面优先,做深做透**,移动端放到"产品验证之后",先招人
|
||||||
|
4. 移动端主控**外包**,核心协议和桌面端自己掌握
|
||||||
|
|
||||||
|
我的建议是 **3 或 1**:先把 Win/Linux 两端做成一款真的好用的产品(含打洞、服务化、音频、文件),移动端等有用户了再投入。现在同时铺 6 个平台,最大概率是每个都半成品。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 给 A / B 的分工调整建议
|
||||||
|
|
||||||
|
| 人 | 主责 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| A | `platform/` 被控侧:Win 抓屏/编码/注入/服务化 + Linux 被控 | 与现状一致 |
|
||||||
|
| B | `proto/` + `conn/` + `session/` + `viewer-core/` + 桌面 UI | 与现状一致 |
|
||||||
|
| — | **移动端(Android/iOS)** | **无人** ← 这是缺口 |
|
||||||
|
|
||||||
|
现有分工在"桌面 + 协议"上是自洽的,**移动端是纯增量,必须补人或砍需求**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 开放问题(需要拍板)
|
||||||
|
|
||||||
|
> §10 第 1 条已于 2026-10-28 裁决,见 §6。
|
||||||
|
|
||||||
|
1. ~~中继:确认走 §6 的 A 方案(v6 优先、留接口不实现)?~~
|
||||||
|
**已裁决:不做中继、不留接口、v6 优先、但保留连接层边界。详见 §6.1 / §6.2。**
|
||||||
|
2. 移动端:先 Android 还是直接砍 iOS?
|
||||||
|
3. UI 框架:确认 Tauri v2,还是先原生桌面 + 后面再说移动?
|
||||||
|
4. 账号体系:现在是"输验证码",要支持设备列表就得有账号/设备 ID 体系——什么时候做?
|
||||||
|
5. 被控服务化后,主控端要不要支持"无人值守开机连接"(有安全含义,需要固定密码 + 白名单)?
|
||||||
124
打洞与信令设计.md
Normal file
124
打洞与信令设计.md
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
# 打洞与信令设计(无中继方案)
|
||||||
|
|
||||||
|
> 状态:协议契约已落在 `common/src/signaling.rs`,已合入 main(`703eb64`)。
|
||||||
|
> 决策:**不做中继(无 TURN / coturn)**,服务器只保留信令 + STUN 职能。
|
||||||
|
> **裁决确认(2026-10-28)**:公司不提供中继服务器,本决策为最终结论,
|
||||||
|
> 同时**不预留中继接口**(理由见《多平台架构设计.md》§6.1 / §6.2)。
|
||||||
|
> 关联:`构建运行手册.md` §7 第 4 项、`Git协作规范.md` 二·B 第 ③ 项。
|
||||||
|
|
||||||
|
## 1. 结论先行:信令不是中继
|
||||||
|
|
||||||
|
| | 信令服务器 | 中继服务器(已砍掉) |
|
||||||
|
|---|---|---|
|
||||||
|
| 承载媒体流量 | ❌ 不承载 | ✅ 全部承载 |
|
||||||
|
| 生命周期 | 只在建连那几秒 | 整个会话期间 |
|
||||||
|
| 成本 | 一台最低配 VPS 足够 | 视频流带宽,主要成本项 |
|
||||||
|
| 必要性 | **必须**(无它则无法交换地址) | 可选(可用率兜底) |
|
||||||
|
|
||||||
|
砍掉中继 ≠ 不要服务器。信令必须保留,因为它解决的是死循环:双方都在 NAT 后,
|
||||||
|
各自只知道自己内网地址,**"自己的公网映射"这个信息只有第三方发包过来时才能看见**。
|
||||||
|
|
||||||
|
## 2. 无中继的代价:可用率,不是功能
|
||||||
|
|
||||||
|
打洞成功率由 NAT 类型决定,与代码质量基本无关:
|
||||||
|
|
||||||
|
| NAT 类型 | 典型场景 | 无中继 |
|
||||||
|
|---|---|---|
|
||||||
|
| Full cone / Restricted cone | 家用路由器默认 | ✅ 很高 |
|
||||||
|
| Port-restricted cone | 家用宽带常见 | ✅ 需同时发包 |
|
||||||
|
| Symmetric NAT | 部分企业网关 | ⚠️ 仅端口可预测时可行 |
|
||||||
|
| CGNAT(`100.64.0.0/10`) | 移动/社区宽带 | ❌ 无解(IPv6 除外) |
|
||||||
|
|
||||||
|
预期 P2P 成功率 **80–90%**,其余连不上。
|
||||||
|
|
||||||
|
**产品边界(需写进对外说明)**:目标用户框定为「家用宽带 + IPv6 可用 + 可开 UPnP」;
|
||||||
|
企业网络 / CGNAT 标注为不支持。这样剩余失败就不是 bug,是已知边界。
|
||||||
|
|
||||||
|
## 3. 打洞流程(协议见 `common/src/signaling.rs`)
|
||||||
|
|
||||||
|
```
|
||||||
|
agent 信令服务器 viewer
|
||||||
|
|-- Register(info) ------->|<------- Register(info) -----|
|
||||||
|
|<-- Registered(observed) -|------ Registered(observed) ->| observed = 公网映射(等价一次 STUN binding)
|
||||||
|
|<-- PeerReady(peer info) -|------ PeerReady(peer info) -->| + 打洞前景 outlook
|
||||||
|
|<-- PunchPlan(start_in) --|------ PunchPlan(start_in) --->| 两侧 start_in 不同,绝对时刻对齐
|
||||||
|
| (双方同时向对端候选列表发包,多轮) |
|
||||||
|
|<========= 打通:UDP 直连 → QUIC 握手 + 证书钉扎 =========>|
|
||||||
|
|--- PunchReport(Connected) ->|<---- PunchReport ----------|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 候选地址与优先级
|
||||||
|
|
||||||
|
`Candidate::score()`:IPv6(+100)> UPnP 显式映射(90) > 本机地址(80) > 公网反射(60) > 端口预测(40)。
|
||||||
|
|
||||||
|
**IPv6 是无中继策略的最强后盾** —— 双方都有公网 v6 就是真直连,成功率≈100%,
|
||||||
|
因此候选列表按 v6 → v4 排序,`EndpointInfo` 两类都带。
|
||||||
|
|
||||||
|
### 3.2 同时发包的时间对齐(不需要时钟同步)
|
||||||
|
|
||||||
|
两侧 RTT 不同,若下发同一个延迟,RTT 大的一方晚发包就打不上。服务器按
|
||||||
|
`punch_start_delay_ms(own_rtt, max_rtt, margin)` 给两侧算不同的 `start_in_ms`:
|
||||||
|
|
||||||
|
```
|
||||||
|
start_in_ms = max_rtt_ms / 2 + margin_ms - own_rtt_ms / 2
|
||||||
|
```
|
||||||
|
|
||||||
|
`start_in_ms` 是**相对消息送达**的延迟,所以两端时钟不需要同步,误差≈单程抖动。
|
||||||
|
|
||||||
|
### 3.3 保活
|
||||||
|
|
||||||
|
NAT 的 UDP 映射默认 30s 左右超时,洞是出站包维持的,超时后静默关闭
|
||||||
|
(表现为"连上了一会儿就断")。`keepalive_secs()` 取双方 `binding_timeout_s`
|
||||||
|
较小值折半,夹在 5–60s。
|
||||||
|
|
||||||
|
## 4. 提高成功率的实现清单(按性价比)
|
||||||
|
|
||||||
|
1. **IPv6 优先**:候选排序已实现;agent 侧需枚举全局 v6 地址填 `CandidateKind::Host`。
|
||||||
|
2. **UPnP / NAT-PMP / PCP**:agent 启动时就尝试为 `PUNCH_UDP_PORT(47800)` 申请映射,
|
||||||
|
成功后以 `CandidateKind::Upnp` 补报(走 `ClientMsg::UpdateCandidates`)。
|
||||||
|
一次成功就把对称 NAT 变成可直连,收益最大。
|
||||||
|
3. **双方同时发包**:见 §3.2,这是打洞能成的关键。
|
||||||
|
4. **端口预测**:`PortAllocation::Sequential/Preserved` 时才值得做,`Predicted` 候选由
|
||||||
|
`sanitize_candidates` 统一清洗(去重、丢端口 0、上限 32 个)。
|
||||||
|
5. **多轮重试**:`rounds=3 / 200ms`,一次不通不代表不通。
|
||||||
|
|
||||||
|
**不要走 TCP 打洞**:QUIC 只跑 UDP,TCP simultaneous open 成功率低,性价比不值得。
|
||||||
|
|
||||||
|
## 5. 实现约束(两端各自注意)
|
||||||
|
|
||||||
|
- **别拿 QUIC 握手当探测**:`quinn::Endpoint::connect` 打不通会持续重试,失败判定被
|
||||||
|
idle timeout 拖很久。应先用同一 UDP socket 发轻量探测包确认通路(秒级),
|
||||||
|
确认后再 `connect`;端口扫描也放这一层,便宜且可并行。
|
||||||
|
- **UDP socket 复用**:打洞用的 socket 要和后续 QUIC 用同一个,否则洞不在同一个映射上。
|
||||||
|
`quinn::Endpoint` 支持同时 `accept()` 与 `connect()`,可以在一个 Endpoint 上做完整流程。
|
||||||
|
- **固定端口**:`47800` 对端口预测和用户手动端口转发都友好,不要随机端口。
|
||||||
|
|
||||||
|
## 6. 安全(本次必须同步改,否则公网不可用)
|
||||||
|
|
||||||
|
1. **证书钉扎**:`viewer/src/main.rs` 的 `SkipVerify` 必须替换为对
|
||||||
|
`EndpointInfo.cert_fingerprint` 的校验(`CertFingerprint` 已定义 hex 编解码)。
|
||||||
|
2. **认证**:`ClientMsg::Register.auth` 必须是一次性验证码(替代固定 `123456`/`888888`),
|
||||||
|
带过期与失败限速,否则公网等于没有认证。
|
||||||
|
3. **候选列表视为不可信输入**:一律经 `sanitize_candidates` 后再发包。
|
||||||
|
|
||||||
|
## 7. 失败必须有明确出口(无中继的用户体验下限)
|
||||||
|
|
||||||
|
没有中继兜底,就必须把"为什么连不上"讲清楚,不允许用户停在"连接中"。
|
||||||
|
`PunchFailReason::user_hint()` 已给出每种原因对应的可执行建议,例如:
|
||||||
|
|
||||||
|
- `Cgnat` → "当前处于运营商级 NAT(大内网),无法直连。请改用 IPv6 网络……"
|
||||||
|
- `SymmetricNat` → "请在被控端路由器开启 UPnP 后重试。"
|
||||||
|
- `UdpBlocked` → "当前网络的防火墙拦截了 UDP 出站,请切换手机热点后重试。"
|
||||||
|
|
||||||
|
`punch_outlook()` 还能在**开打之前**就给出前景(`Likely/Possible/Unlikely/Hopeless`),
|
||||||
|
`Hopeless`(CGNAT)时可直接跳过打洞轮次并立刻提示,不让用户白等。
|
||||||
|
|
||||||
|
## 8. 后续动作
|
||||||
|
|
||||||
|
| 顺序 | 事项 | 负责 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `common/src/signaling.rs` 过审合入 main | B 提 PR / A review |
|
||||||
|
| 2 | 信令服务最小实现(WSS/443,配对 + 回推 observed + 下发 PunchPlan) | B |
|
||||||
|
| 3 | agent:候选收集(v6/Host/UPnP/Reflexive)、同步打洞、保活 | A |
|
||||||
|
| 4 | viewer:候选收集、同步打洞、改为证书钉扎 | B |
|
||||||
|
| 5 | 双机联调:家庭宽带 / 手机热点 / CGNAT 三种网络各跑一轮 | A + B |
|
||||||
135
打洞验证记录.md
Normal file
135
打洞验证记录.md
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
# 打洞连通性验证记录
|
||||||
|
|
||||||
|
> 目的:用最小成本确认「自研 P2P 打洞」在本项目目标网络下是否可行,再决定要不要投入实现。
|
||||||
|
> 工具:`tools/punch_probe.py`(探针)、`tools/rendezvous.py`(极简信令服务器)
|
||||||
|
> 结论:**本机线路为锥形 NAT + 端口保留,打洞可行性高。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 测试环境
|
||||||
|
|
||||||
|
| 角色 | 地址 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 信令服务器 | `47.98.245.66:47999/udp` | 阿里云 ECS,Ubuntu 24.04.2,python 3.12.3 |
|
||||||
|
| 客户端 | 出口 `116.3.201.115` | 联通线路,位于 NAT 之后 |
|
||||||
|
| 工具 | `tools/rendezvous.py` + `tools/punch_probe.py` | 纯 Python 标准库 |
|
||||||
|
|
||||||
|
部署位置:`/root/uu-punch-poc/`(与服务器上其他服务完全隔离)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 验证过程与结果
|
||||||
|
|
||||||
|
| # | 验证项 | 结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 信令服务器回环注册 | ✅ 能注册、能记录 |
|
||||||
|
| 2 | 服务器进程/监听 | ✅ `0.0.0.0:47999`,长期运行稳定 |
|
||||||
|
| 3 | **跨公网注册** | ✅ 服务器日志出现 `116.3.201.115:47800` |
|
||||||
|
| 4 | **双方配对与地址回推** | ✅ `配对成功:116.3.201.115:47800 <==> :47801` |
|
||||||
|
| 5 | 同一 NAT 内两实例互打 | ❌ 失败(见 §4.1,属 hairpinning 限制) |
|
||||||
|
| 6 | **NAT 映射行为判定** | ✅ **端点无关映射 + 端口保留**(见 §3) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 关键结论:NAT 类型 = 锥形 + 端口保留
|
||||||
|
|
||||||
|
用**同一个本地端口 47800** 分别打两个完全不同的目标,观察公网映射:
|
||||||
|
|
||||||
|
| 目标 | 观察到的公网映射 |
|
||||||
|
|---|---|
|
||||||
|
| Google STUN(境外) | `116.3.201.115:47800` |
|
||||||
|
| 自建信令服务器 `47.98.245.66:47999` | `116.3.201.115:47800` |
|
||||||
|
|
||||||
|
**不同目标 → 同一个公网端口 → 端点无关映射(Endpoint-Independent Mapping,即锥形 NAT),且端口原样保留。**
|
||||||
|
|
||||||
|
按打洞成功率排序:
|
||||||
|
|
||||||
|
```
|
||||||
|
锥形 + 端口保留 ← 本机(最优)
|
||||||
|
锥形 + 端口改写 成功率高
|
||||||
|
对称 NAT 困难,需端口预测
|
||||||
|
CGNAT 基本无解
|
||||||
|
```
|
||||||
|
|
||||||
|
**推论**:信令服务器观察到的地址,对该客户端是**可复用的真实映射**,对端直接向它发包即可打洞,无需端口预测。这是最理想的情形。
|
||||||
|
|
||||||
|
复现命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 一定要带 --stun-port,用与打洞相同的端口,否则映射不可比
|
||||||
|
py tools\punch_probe.py --stun --stun-port 47800
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 踩到的坑(Rust 实现必须处理)
|
||||||
|
|
||||||
|
### 4.1 hairpinning:同一公网 IP 下两实例互打会失败
|
||||||
|
|
||||||
|
两个客户端都在 `116.3.201.115` 后面,互相打对方的公网地址,需要路由器支持 **NAT 环回(hairpinning)**。本线路不支持。
|
||||||
|
|
||||||
|
**这不代表真实打洞会失败**——真实场景是两台机器位于**不同**公网 IP 之后。此测试只能验证"信令配对"链路,不能验证穿透能力。
|
||||||
|
|
||||||
|
### 4.2 Windows:ICMP 端口不可达会打断接收循环
|
||||||
|
|
||||||
|
对方尚未启动时发包 → 收到 ICMP port unreachable → 下一次 `recv` 抛 `WSAECONNRESET(10054)`。若把它当致命错误 `break`,接收循环就此死掉,之后对方真打进来也收不到。
|
||||||
|
|
||||||
|
处理方式(两层):
|
||||||
|
|
||||||
|
```python
|
||||||
|
sock.ioctl(socket.SIO_UDP_CONNRESET, False) # 1) 关闭该行为
|
||||||
|
# 2) 收到该错误时 continue,绝不能 break
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rust 对应**:`tokio` 的 `recv_from` 会返回 `ConnectionReset`,同样不能 `?` 或 `break`。
|
||||||
|
|
||||||
|
### 4.3 发完就退出会漏掉晚到的回包
|
||||||
|
|
||||||
|
对方可能比自己晚启动。发完最后一包立即关闭 socket,会把"晚到的成功"误判为失败。
|
||||||
|
→ 发完后保留 grace 期(默认 2.5s)继续监听。
|
||||||
|
|
||||||
|
### 4.4 部署层的两道防火墙
|
||||||
|
|
||||||
|
| 层 | 现象 | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 主机 ufw | `INPUT DROP`,无规则则静默丢弃 | `ufw allow 47999/udp` |
|
||||||
|
| 云安全组 | 包到不了网卡(tcpdump 零捕获) | 控制台加 UDP 入方向规则 |
|
||||||
|
|
||||||
|
**排查手法**:`tcpdump -n -i any udp port <port>` 零捕获 = 包在到达主机前就被丢,问题在云安全组而不在主机防火墙。
|
||||||
|
|
||||||
|
> 注意:首次配置时误把安全组规则设成了 **TCP**,UDP 包仍被丢弃。协议类型必须选 UDP。
|
||||||
|
|
||||||
|
### 4.5 假阳性:信令回复被误判为"打通"
|
||||||
|
|
||||||
|
客户端在拿到对端地址前会反复注册,服务器的 `PEER` / `WAIT` 回复可能在 `discover_peer` 返回之后才到达。若接收线程不做区分,会把**服务器发来的回复**当成"对方打进来了",输出假的"✅ 打洞成功"。
|
||||||
|
|
||||||
|
实测踩到的形态:报告的"对方源地址"是**信令服务器的 IP:端口**(`47.98.245.66:47999`),包长 22 字节——而真正的打洞包固定是 **16 字节**(`PUNCHv1|` + 8 字节时间戳)。
|
||||||
|
|
||||||
|
**判据(Rust 实现必须照做)**:
|
||||||
|
|
||||||
|
1. 只认以协议魔数开头的包,长度必须是约定值
|
||||||
|
2. 显式排除信令服务器的 `IP:端口`
|
||||||
|
3. 结论里如果出现"非打洞包 N 个",说明收到了信令回复,**不能**据此判定成功
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 尚未验证
|
||||||
|
|
||||||
|
| 项 | 为什么重要 | 怎么验 |
|
||||||
|
|---|---|---|
|
||||||
|
| **两台不同网络的机器互打** | 唯一能证明真实穿透能力的测试 | 一台机器 + 一台连手机热点的笔记本 |
|
||||||
|
| 手机热点(CGNAT)场景 | 决定"无中继"方案对移动端是否成立 | 同上,热点那端即为 CGNAT |
|
||||||
|
| IPv6 直连 | 若两端都有公网 v6,可绕开 NAT | `--peer [v6地址]:端口` |
|
||||||
|
| 对端 NAT 是否同样友好 | 打洞需要两端配合 | 在对方网络重复 §3 的映射判定 |
|
||||||
|
|
||||||
|
**下一步优先级:手机热点组合 > IPv6 > 其他。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 对实现的影响
|
||||||
|
|
||||||
|
1. **信令服务器的职责就此确认**:注册、配对、回推观察到的源地址。不承载媒体,建连后即退出。
|
||||||
|
2. **信令与打洞必须复用同一个 socket**:服务器看到的映射端口,必须就是打洞时使用的端口,否则映射不可复用。
|
||||||
|
- Rust 实现中:该 socket 需继续交给 `quinn::Endpoint` 使用(一次绑定,三处复用:信令 / 打洞 / QUIC)。
|
||||||
|
3. **失败判定要快**:先做 NAT 行为探测,若判定为对称 NAT 直接给出结论,不必耗完多轮。
|
||||||
|
4. **保活**:NAT 表项会超时,建连后需按 `binding_timeout_s` 折半做 keepalive(协议中已定义 `keepalive_secs()`)。
|
||||||
35
构建运行手册.md
35
构建运行手册.md
@ -20,31 +20,34 @@
|
|||||||
- `rustup default stable-x86_64-pc-windows-gnu`,装到 `%USERPROFILE%\.cargo\bin`
|
- `rustup default stable-x86_64-pc-windows-gnu`,装到 `%USERPROFILE%\.cargo\bin`
|
||||||
- MinGW-w64:`D:\mingw64\bin`(gcc 16.2.0)
|
- MinGW-w64:`D:\mingw64\bin`(gcc 16.2.0)
|
||||||
- 构建时 PATH 需同时包含上面两个目录(缺 MinGW 会在链接 openh264 的 C++ 时失败)
|
- 构建时 PATH 需同时包含上面两个目录(缺 MinGW 会在链接 openh264 的 C++ 时失败)
|
||||||
- **cargo 源**:国内直连 crates.io 会超时(cargo 不读系统代理),需配置镜像。已在本机
|
- **cargo 网络**:国内直连 crates.io 会超时,且 **cargo 不读 Windows 系统代理**,必须显式告诉它。
|
||||||
`%USERPROFILE%\.cargo\config.toml` 写入 USTC 源:
|
已在本机 `%USERPROFILE%\.cargo\config.toml` 写入(Clash 之类混合端口,按实际改):
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[source.crates-io]
|
[http]
|
||||||
replace-with = "ustc"
|
proxy = "http://127.0.0.1:7897"
|
||||||
[source.ustc]
|
|
||||||
registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
⚠️ **不要用国内镜像做源替换**:实测 USTC 镜像的索引 `cksum` 与其实际下发的 `.crate`
|
||||||
|
字节**不一致**(`nvenc 0.1.0`:索引写 `3227c3b0…`,包体实际 `ab6fde0e…`),
|
||||||
|
会报 `failed to verify the checksum of nvenc`;且源替换会改变 registry 身份,
|
||||||
|
使 `Cargo.lock` 里已有的 checksum 与全部缓存失配。**走官方源 + 代理最稳。**
|
||||||
|
|
||||||
> 注:旧版本手册写的 `D:\wordkb\...\uurev\tools\rust` 路径已不存在,按上面重新准备工具链即可。
|
> 注:旧版本手册写的 `D:\wordkb\...\uurev\tools\rust` 路径已不存在,按上面重新准备工具链即可。
|
||||||
|
|
||||||
## 3. 构建
|
## 3. 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export RUSTUP_HOME="D:\\wordkb\\2026-10-22-15-52-47\\uurev\\tools\\rust\\rustup"
|
# Git Bash 写法:PATH 需含 rustup 的 cargo 与 MinGW-w64
|
||||||
export CARGO_HOME="D:\\wordkb\\2026-10-22-15-52-47\\uurev\\tools\\rust\\cargo"
|
export PATH="$USERPROFILE/.cargo/bin:/d/mingw64/bin:$PATH"
|
||||||
export PATH="/d/wordkb/2026-10-22-15-52-47/uurev/tools/rust/cargo/bin:/d/wordkb/2026-10-22-15-52-47/uurev/tools/mingw64/bin:$PATH"
|
cd /d/code/remote # 换成你自己的工作目录 / worktree
|
||||||
cd /d/wordkb/2026-10-22-15-52-47/mvp-remote
|
|
||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
产物:
|
产物:
|
||||||
- 被控端 `target/release/agent.exe`
|
- 被控端 `target/release/agent.exe`
|
||||||
- 主控端 `target/release/viewer.exe`
|
- 主控端 `target/release/viewer.exe`
|
||||||
|
- 编码基准(可选)`target/release/encbench.exe`
|
||||||
|
|
||||||
## 4. 运行(两台机器,或同机双开验证)
|
## 4. 运行(两台机器,或同机双开验证)
|
||||||
|
|
||||||
@ -164,9 +167,15 @@ viewer.exe --addr <被控机IP> --port 47800 --code 123456
|
|||||||
bash scripts/linux-smoke.sh
|
bash scripts/linux-smoke.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本自建 Xvfb 虚拟屏后跑端到端测试,覆盖:X11 采集 → openh264 编码 → QUIC 下行解帧、
|
脚本自建 Xvfb 虚拟屏后跑**单测 + 端到端**两层:
|
||||||
验证码认证(含**错误验证码被拒**)、Ping/Pong(含下行丢包统计字段)、静止桌面下的关键帧
|
|
||||||
恢复,以及**输入注入实证**——注入鼠标移动后,从另一条 X11 连接读回指针真实坐标。
|
- 单测(不需要 X server):键码映射、坐标换算等纯逻辑;
|
||||||
|
- 端到端:X11 采集 → openh264 编码 → QUIC 下行解帧、验证码认证(含**错误验证码被拒**)、
|
||||||
|
Ping/Pong(含下行丢包统计字段)、静止桌面下的关键帧恢复,以及**输入注入实证**——
|
||||||
|
注入鼠标移动后,从另一条 X11 连接读回指针真实坐标。
|
||||||
|
|
||||||
|
> 单测那层别省:坐标换算的取整边界就是靠它才发现的(集成测试有 ±2 像素容差,
|
||||||
|
> 期望值写错也照样绿)。
|
||||||
|
|
||||||
> 静止关键帧那条用例做过**变异测试**:把修复退回会失败、恢复后通过,确认断言不是摆设。
|
> 静止关键帧那条用例做过**变异测试**:把修复退回会失败、恢复后通过,确认断言不是摆设。
|
||||||
> 注意扰动量必须小(画 64x64 小方块)——整屏刷白会触发编码器场景切换自动 IDR,
|
> 注意扰动量必须小(画 64x64 小方块)——整屏刷白会触发编码器场景切换自动 IDR,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user