mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-10 06:21:02 +03:00
Compare commits
64 Commits
hdr-tonema
...
04b3e1f40b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b3e1f40b | ||
|
|
01b4f1cad7 | ||
|
|
0b059b4257 | ||
|
|
8fff6d55d5 | ||
|
|
72c445aeee | ||
|
|
c2fd869eeb | ||
|
|
0351522c60 | ||
|
|
ca5d1e0067 | ||
|
|
c78de1c1e7 | ||
|
|
f4b366d7ba | ||
|
|
70084153e9 | ||
|
|
f5780e5417 | ||
|
|
e25880c281 | ||
|
|
4fd93ba8f2 | ||
|
|
4490183930 | ||
|
|
b65d32c3c6 | ||
|
|
91079e7e48 | ||
|
|
80ff51c590 | ||
|
|
5daea936ab | ||
|
|
0dce2a81a0 | ||
|
|
5f223617d9 | ||
|
|
1aba5f2fde | ||
|
|
484ba864b7 | ||
|
|
6e78afe061 | ||
|
|
bef87963e5 | ||
|
|
e585993ffb | ||
|
|
a1263a8c77 | ||
|
|
dbd3f04f35 | ||
|
|
c1b50f3910 | ||
|
|
2639ede4c4 | ||
|
|
2227f161df | ||
|
|
654344dbd9 | ||
|
|
79f7a72fb7 | ||
|
|
83f1662f6e | ||
|
|
bea0d1d2f9 | ||
|
|
6c7e3c1370 | ||
|
|
124bbfd8dc | ||
|
|
2ba2d0a72b | ||
|
|
ac9e3df9a8 | ||
|
|
48ef3059c7 | ||
|
|
759d093c28 | ||
|
|
770c94b639 | ||
|
|
7cfc10d932 | ||
|
|
70a124696d | ||
|
|
d76b98f7c0 | ||
|
|
2b9a6ef7b0 | ||
|
|
362966cde9 | ||
|
|
8da465f1d1 | ||
|
|
d45cb8a5f8 | ||
|
|
f5c2ff7e25 | ||
|
|
86c4ddbb1e | ||
|
|
c34f29dd30 | ||
|
|
49dc85b9c2 | ||
|
|
2771979eb2 | ||
|
|
de3588313a | ||
|
|
3c7ea8c075 | ||
|
|
3f93005be2 | ||
|
|
23a147b0dc | ||
|
|
e4539fc304 | ||
|
|
6dbd810454 | ||
|
|
0fd1a0eecb | ||
|
|
dfb5804dd0 | ||
|
|
957dfe8c96 | ||
|
|
c312385ffd |
68
.github/workflows/flutter-build.yml
vendored
68
.github/workflows/flutter-build.yml
vendored
@@ -389,6 +389,54 @@ jobs:
|
||||
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||
sha256sum ../../SignOutput/rustdesk-*.msi
|
||||
|
||||
- name: Build pre-built MSI template
|
||||
# Two things this works around: preprocess.py rewrites res/msi in place, so the
|
||||
# tree is reset around this second variant; and it locates the app as
|
||||
# <app-name>.exe inside the dist, so the dist copy is renamed to match.
|
||||
#
|
||||
# The placeholder is chosen to keep this template as close to the shipped msi as
|
||||
# possible: eight characters like "RustDesk", and a valid 8.3 name, so WiX
|
||||
# derives no short name for it. A longer placeholder would get one, and a patch
|
||||
# cannot rewrite a truncated placeholder, leaving short names pointing at it.
|
||||
#
|
||||
# It still has to be unique, which is why "RustDesk" itself cannot be used:
|
||||
# it also names payload that must never be renamed, such as librustdesk.dll
|
||||
# and drivers\RustDeskPrinterDriver.
|
||||
#
|
||||
#
|
||||
# Building the arm64 template on the native arm64 runner makes the ARM
|
||||
# package available: the build agents are x64 and cannot run
|
||||
# preprocess.py against an ARM exe.
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
git checkout -- res/msi
|
||||
cp -r ./rustdesk ./rustdesk-msi-template
|
||||
mv ./rustdesk-msi-template/rustdesk.exe ./rustdesk-msi-template/RDAPPNAM.exe
|
||||
Set-Content -Path ./rustdesk-msi-template/custom.txt -Value 'placeholder' -NoNewline
|
||||
$assets = './rustdesk-msi-template/data/flutter_assets/assets'
|
||||
New-Item -ItemType Directory -Force -Path $assets | Out-Null
|
||||
foreach ($a in 'icon.ico','icon.png','logo.png','logo_light.png','logo_dark.png') {
|
||||
Set-Content -Path "$assets/$a" -Value 'placeholder' -NoNewline
|
||||
}
|
||||
pushd ./res/msi
|
||||
python preprocess.py --arp --template --revision-version 0 -d ../../rustdesk-msi-template --app-name RDAPPNAM
|
||||
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
|
||||
msbuild msi.sln -t:clean -p:Configuration=Release -p:Platform=$msiPlatform
|
||||
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
|
||||
$msi = Get-ChildItem ./Package/bin/*/Release/en-us/Package.msi | Select-Object -First 1
|
||||
popd
|
||||
mkdir ./msi-template
|
||||
mv $msi.FullName ./msi-template/rustdesk-template-${{ matrix.job.arch }}.msi
|
||||
git checkout -- res/msi
|
||||
rm -r -fo ./rustdesk-msi-template
|
||||
|
||||
- name: Upload unsigned msi template
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-${{ matrix.job.arch }}
|
||||
path: ./msi-template
|
||||
|
||||
- name: Sign rustdesk self-extracted file
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
@@ -925,15 +973,33 @@ jobs:
|
||||
name: rustdesk-unsigned-windows-x86_64
|
||||
path: ./windows-x86_64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-aarch64
|
||||
path: ./windows-aarch64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-x86
|
||||
path: ./windows-x86/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-x86_64
|
||||
path: ./msi-template/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-aarch64
|
||||
path: ./msi-template/
|
||||
|
||||
- name: Combine unsigned app
|
||||
run: |
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-aarch64 windows-x86 msi-template
|
||||
|
||||
- name: Publish unsigned app
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
|
||||
302
Cargo.lock
generated
302
Cargo.lock
generated
@@ -753,24 +753,6 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.71.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.12.1",
|
||||
"proc-macro2 1.0.93",
|
||||
"quote 1.0.36",
|
||||
"regex",
|
||||
"rustc-hash 2.1.1",
|
||||
"shlex",
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
@@ -1161,30 +1143,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.41"
|
||||
@@ -1234,7 +1192,6 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2324,7 +2281,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
dependencies = [
|
||||
"libloading 0.8.4",
|
||||
"libloading 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2442,42 +2399,6 @@ dependencies = [
|
||||
"linux-raw-sys 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtls"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f531dd7c181beaf3cebab3716afa4d0d41ab888be85232583f56bbaf07ca208a"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"bincode",
|
||||
"byteorder",
|
||||
"cbc",
|
||||
"ccm",
|
||||
"chacha20poly1305",
|
||||
"der-parser",
|
||||
"hmac",
|
||||
"log",
|
||||
"p256",
|
||||
"p384",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand_core 0.6.4",
|
||||
"rcgen",
|
||||
"ring",
|
||||
"rustls",
|
||||
"sec1",
|
||||
"serde 1.0.228",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
"x25519-dalek",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "0.4.8"
|
||||
@@ -2863,7 +2784,7 @@ dependencies = [
|
||||
"is-terminal",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"nu-ansi-term 0.49.0",
|
||||
"nu-ansi-term",
|
||||
"regex",
|
||||
"thiserror 1.0.61",
|
||||
]
|
||||
@@ -3766,6 +3687,7 @@ dependencies = [
|
||||
"mac_address",
|
||||
"machine-uid",
|
||||
"osascript",
|
||||
"percent-encoding",
|
||||
"protobuf",
|
||||
"protobuf-codegen",
|
||||
"rand 0.8.5",
|
||||
@@ -4177,16 +4099,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "interceptor"
|
||||
version = "0.15.0"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea51375727680dc15f06e8ad90fa31df75d79dd030100e8ad60eef1c27fe2c98"
|
||||
checksum = "1ac0781c825d602095113772e389ef0607afcb869ae0e68a590d8e0799cdcef8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"rtcp",
|
||||
"rtp",
|
||||
"thiserror 1.0.61",
|
||||
@@ -4338,11 +4259,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "kcp-sys"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/rustdesk-org/kcp-sys#32a6c09fc6223f54aea83981a6aa8995931d29be"
|
||||
source = "git+https://github.com/rustdesk-org/kcp-sys?branch=rustdesk-patches#023a0065398968989f2ddfcf5cc72bb886d02675"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"auto_impl",
|
||||
"bindgen 0.71.1",
|
||||
"bindgen 0.72.1",
|
||||
"bitflags 2.9.1",
|
||||
"bytes",
|
||||
"cc",
|
||||
@@ -4353,8 +4274,6 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"zerocopy 0.7.34",
|
||||
]
|
||||
|
||||
@@ -4488,7 +4407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5210,16 +5129,6 @@ dependencies = [
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
|
||||
dependencies = [
|
||||
"overload",
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.49.0"
|
||||
@@ -5883,12 +5792,6 @@ dependencies = [
|
||||
"serde_json 1.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.25.1"
|
||||
@@ -6277,17 +6180,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -7094,9 +6986,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rtcp"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81d30d1c4091644431c22acf9f8be6191b56805e0e977f15ca7104b4a6d6eaec"
|
||||
checksum = "e9689528bf3a9eb311fd938d05516dd546412f9ce4fffc8acfc1db27cc3dbf72"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"thiserror 1.0.61",
|
||||
@@ -7105,14 +6997,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rtp"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f126f38ea84c02480e32e547c1459a939052f74fb92117ac3eef23fdac6b023"
|
||||
checksum = "c54733451a67d76caf9caa07a7a2cec6871ea9dda92a7847f98063d459200f4b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"serde 1.0.228",
|
||||
"thiserror 1.0.61",
|
||||
"webrtc-util",
|
||||
@@ -7267,6 +7159,7 @@ dependencies = [
|
||||
"terminfo",
|
||||
"termios 0.3.3",
|
||||
"tiny-skia",
|
||||
"tokio",
|
||||
"totp-rs",
|
||||
"tray-icon",
|
||||
"ttf-parser",
|
||||
@@ -7547,11 +7440,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sdp"
|
||||
version = "0.10.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32c374dceda16965d541c8800ce9cc4e1c14acfd661ddf7952feeedc3411e5c6"
|
||||
checksum = "4cd277015eada44a0bb810a4b84d3bf6e810573fa62fb442f457edf6a1087a69"
|
||||
dependencies = [
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"substring",
|
||||
"thiserror 1.0.61",
|
||||
"url",
|
||||
@@ -7781,15 +7674,6 @@ dependencies = [
|
||||
"tzdb 0.5.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
@@ -8129,15 +8013,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stun"
|
||||
version = "0.9.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a512c5d501e3e3b5a4bb3e8e31462d56d54a66b95a28b8596e14422bf21c32b"
|
||||
checksum = "7dbc2bab375524093c143dc362a03fb6a1fb79e938391cdb21665688f88a088a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"crc",
|
||||
"lazy_static",
|
||||
"md-5",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"subtle",
|
||||
"thiserror 1.0.61",
|
||||
@@ -8519,16 +8403,6 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "threadpool"
|
||||
version = "1.8.1"
|
||||
@@ -8908,32 +8782,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
|
||||
dependencies = [
|
||||
"nu-ansi-term 0.46.0",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9048,9 +8896,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "turn"
|
||||
version = "0.11.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ed995882f66ab94238de77c62e5e778389698ab700afa4696f4754da8f457cb"
|
||||
checksum = "3f5aea1116456e1da71c45586b87c72e3b43164fbf435eb93ff6aa475416a9a4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -9058,7 +8906,7 @@ dependencies = [
|
||||
"log",
|
||||
"md-5",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"stun",
|
||||
"thiserror 1.0.61",
|
||||
@@ -9184,12 +9032,6 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.15"
|
||||
@@ -9335,12 +9177,6 @@ dependencies = [
|
||||
"bindgen 0.65.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -9724,25 +9560,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08fd686c0920ac08f3a57eacc48e31f0e4ca1ffefba4478784606f78c14e83ad"
|
||||
checksum = "24bab7195998d605c862772f90a452ba655b90a2f463c850ac032038890e367a"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"dtls",
|
||||
"cfg-if 1.0.0",
|
||||
"hex",
|
||||
"interceptor",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"rcgen",
|
||||
"regex",
|
||||
"ring",
|
||||
"rtcp",
|
||||
"rtp",
|
||||
"rustls",
|
||||
"sdp",
|
||||
"serde 1.0.228",
|
||||
"serde_json 1.0.118",
|
||||
@@ -9750,12 +9587,13 @@ dependencies = [
|
||||
"smol_str",
|
||||
"stun",
|
||||
"thiserror 1.0.61",
|
||||
"time 0.3.36",
|
||||
"tokio",
|
||||
"turn",
|
||||
"unicase",
|
||||
"url",
|
||||
"waitgroup",
|
||||
"webrtc-data",
|
||||
"webrtc-dtls",
|
||||
"webrtc-ice",
|
||||
"webrtc-mdns",
|
||||
"webrtc-media",
|
||||
@@ -9766,9 +9604,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-data"
|
||||
version = "0.12.0"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "062a5438d63bb0756a221693d76cc0dd6119affee1dfdfe57abe3a2a8c8b3eea"
|
||||
checksum = "4e97b932854da633a767eff0cc805425a2222fc6481e96f463e57b015d949d1d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"log",
|
||||
@@ -9780,17 +9618,54 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-ice"
|
||||
version = "0.14.0"
|
||||
name = "webrtc-dtls"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cb13fd1a373e68addc4bba0c8ca058627518e54342583d024bdcbb8ae5d97d"
|
||||
checksum = "5ccbe4d9049390ab52695c3646c1395c877e16c15fb05d3bda8eee0c7351711c"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"bincode",
|
||||
"byteorder",
|
||||
"cbc",
|
||||
"ccm",
|
||||
"der-parser",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"log",
|
||||
"p256",
|
||||
"p384",
|
||||
"portable-atomic",
|
||||
"rand 0.8.5",
|
||||
"rand_core 0.6.4",
|
||||
"rcgen",
|
||||
"ring",
|
||||
"rustls",
|
||||
"sec1",
|
||||
"serde 1.0.228",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
"x25519-dalek",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-ice"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb51bde0d790f109a15bfe4d04f1b56fb51d567da231643cb3f21bb74d678997"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"crc",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"serde 1.0.228",
|
||||
"serde_json 1.0.118",
|
||||
"stun",
|
||||
@@ -9806,9 +9681,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-mdns"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a17279a067e75df72ce923fdeb7f04cd808f6f5aa4910dc6bcb4fbe66b396ace"
|
||||
checksum = "979cc85259c53b7b620803509d10d35e2546fa505d228850cbe3f08765ea6ea8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"socket2 0.5.10",
|
||||
@@ -9819,22 +9694,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-media"
|
||||
version = "0.11.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94a84c910fec0848fd5a0d8a5651e0ddbdedaf25a7d3ae3f0b15f71ac73a1773"
|
||||
checksum = "80041211deccda758a3e19aa93d6b10bc1d37c9183b519054b40a83691d13810"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"rtp",
|
||||
"thiserror 1.0.61",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-sctp"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f985465467d8910c1f8ac4382cd64f83b1f6a1a75021a82b221546f6fb3b856f"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=825a0a4862818f74406d8e1cc25be72259228235#825a0a4862818f74406d8e1cc25be72259228235"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9842,7 +9716,7 @@ dependencies = [
|
||||
"crc",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
@@ -9850,9 +9724,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-srtp"
|
||||
version = "0.16.0"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66d8cdc33413f1d0192670a80ce93d17cb78d57fe3a2414be30d6f6dff121123"
|
||||
checksum = "01e773f79b09b057ffbda6b03fe7b43403b012a240cf8d05d630674c3723b5bb"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
@@ -9873,19 +9747,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-util"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1c0c7e0c8f280f2bbfae442701465777ac07adaf46ce0c5863cd58e13fe472a"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=825a0a4862818f74406d8e1cc25be72259228235#825a0a4862818f74406d8e1cc25be72259228235"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags 1.3.2",
|
||||
"bytes",
|
||||
"ipnet",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.26.4",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"winapi 0.3.9",
|
||||
|
||||
15
Cargo.toml
15
Cargo.toml
@@ -52,7 +52,7 @@ screencapturekit = ["cpal/screencapturekit"]
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
scrap = { path = "libs/scrap", features = ["wayland"] }
|
||||
hbb_common = { path = "libs/hbb_common" }
|
||||
hbb_common = { path = "libs/hbb_common", features = ["webrtc"] }
|
||||
serde_derive = "1.0"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
@@ -83,7 +83,7 @@ fon = "0.6"
|
||||
shutdown_hooks = "0.1"
|
||||
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
|
||||
stunclient = "0.4"
|
||||
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
|
||||
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys", branch = "rustdesk-patches" }
|
||||
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip", "zstd"], default-features=false }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
@@ -215,6 +215,16 @@ exclude = ["vdi/host"]
|
||||
# This allows building and running on systems without libxdo installed (e.g., Wayland-only)
|
||||
[patch.crates-io]
|
||||
libxdo-sys = { path = "libs/libxdo-sys-stub" }
|
||||
# One branch off upstream v0.13.0, the tag whose crate versions match this stack.
|
||||
# webrtc-util: reads the Windows adapter list's IPv6 addresses as host-order u16 groups, so every
|
||||
# one comes out byte-swapped, fails to bind, and ICE gathers no IPv6 host candidate on Windows.
|
||||
# webrtc-sctp: RFC 4960's 1s RTO floor makes a single loss cost 1-3s on a link whose RTT is 24-64ms,
|
||||
# and fast retransmit cannot cover a request/response exchange; INITIAL_MTU 1228 also fragments on
|
||||
# IPv6; and its AIMD pins a lossy long-haul link to MSS/(RTT*sqrt(p)), so a switch sends without
|
||||
# a congestion window, as KCP does - on by default, `allow-webrtc-congestion-control` opts back in.
|
||||
# Pinned by rev, not branch: a fork branch can be rewritten out from under the lockfile.
|
||||
webrtc-util = { git = "https://github.com/rustdesk-org/webrtc", rev = "825a0a4862818f74406d8e1cc25be72259228235" }
|
||||
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "825a0a4862818f74406d8e1cc25be72259228235" }
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||
@@ -234,6 +244,7 @@ os-version = "0.2"
|
||||
[dev-dependencies]
|
||||
hound = "3.5"
|
||||
docopt = "1.1"
|
||||
tokio = { version = "1.44", features = ["test-util"] }
|
||||
|
||||
[package.metadata.bundle]
|
||||
name = "RustDesk"
|
||||
|
||||
12
build.rs
12
build.rs
@@ -43,6 +43,15 @@ fn build_manifest() {
|
||||
}
|
||||
}
|
||||
|
||||
// bionic only exports getifaddrs()/freeifaddrs() from API 24, while the jniLibs
|
||||
// are built against the API 21 sysroot (flutter/ndk_*.sh). webrtc-util calls
|
||||
// them, so without this the android link fails on undefined symbols.
|
||||
fn build_android_ifaddrs() {
|
||||
let file = "src/platform/android_ifaddrs.c";
|
||||
cc::Build::new().file(file).compile("android_ifaddrs");
|
||||
println!("cargo:rerun-if-changed={}", file);
|
||||
}
|
||||
|
||||
fn install_android_deps() {
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||
if target_os != "android" {
|
||||
@@ -89,5 +98,8 @@ fn main() {
|
||||
build_mac();
|
||||
println!("cargo:rustc-link-lib=framework=ApplicationServices");
|
||||
}
|
||||
if target_os == "android" {
|
||||
build_android_ifaddrs();
|
||||
}
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
|
||||
@@ -1633,7 +1633,8 @@ String bool2option(String option, bool b) {
|
||||
String res;
|
||||
if (option.startsWith('enable-') &&
|
||||
option != kOptionEnableUdpPunch &&
|
||||
option != kOptionEnableIpv6Punch) {
|
||||
option != kOptionEnableIpv6Punch &&
|
||||
option != kOptionEnableWebrtc) {
|
||||
res = b ? defaultOptionYes : 'N';
|
||||
} else if (option.startsWith('allow-') ||
|
||||
option == kOptionStopService ||
|
||||
|
||||
@@ -606,6 +606,9 @@ class QualityMonitor extends StatelessWidget {
|
||||
_row(
|
||||
"Codec", qualityMonitorModel.data.codecFormat ?? '-'),
|
||||
_row("Chroma", qualityMonitorModel.data.chroma ?? '-'),
|
||||
if (qualityMonitorModel.webrtcTransport != null)
|
||||
_row("Transport",
|
||||
qualityMonitorModel.webrtcTransport!),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -166,10 +166,12 @@ const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper";
|
||||
const String kOptionStopService = "stop-service";
|
||||
const String kOptionDirectxCapture = "enable-directx-capture";
|
||||
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
|
||||
const String kOptionEnableTcpPunch = "enable-tcp-punch";
|
||||
const String kOptionEnableUdpPunch = "enable-udp-punch";
|
||||
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
|
||||
const String kOptionAllowSyncClipboardBetweenSessions =
|
||||
"allow-sync-clipboard-between-sessions";
|
||||
const String kOptionEnableWebrtc = "enable-webrtc";
|
||||
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
||||
const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||
|
||||
@@ -563,6 +563,12 @@ class _GeneralState extends State<_General> {
|
||||
kOptionDirectxCapture,
|
||||
),
|
||||
if (!isWeb && !incomingOnly) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable TCP hole punching',
|
||||
kOptionEnableTcpPunch,
|
||||
isServer: false,
|
||||
),
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable UDP hole punching',
|
||||
@@ -575,6 +581,15 @@ class _GeneralState extends State<_General> {
|
||||
kOptionEnableIpv6Punch,
|
||||
isServer: false,
|
||||
),
|
||||
],
|
||||
if (!incomingOnly)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable WebRTC P2P connection',
|
||||
kOptionEnableWebrtc,
|
||||
isServer: false,
|
||||
),
|
||||
if (!isWeb && !incomingOnly)
|
||||
Tooltip(
|
||||
message: translate('sync-clipboard-between-sessions-tip'),
|
||||
child: _OptionCheckBox(
|
||||
@@ -584,7 +599,6 @@ class _GeneralState extends State<_General> {
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
// Add client-side wakelock option for desktop platforms
|
||||
|
||||
@@ -97,10 +97,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _hideNetwork = false;
|
||||
var _hideWebSocket = false;
|
||||
var _enableTrustedDevices = false;
|
||||
var _enableTcpPunch = false;
|
||||
var _enableUdpPunch = false;
|
||||
var _allowInsecureTlsFallback = false;
|
||||
var _disableUdp = false;
|
||||
var _enableIpv6Punch = false;
|
||||
var _enableWebrtc = false;
|
||||
var _isUsingPublicServer = false;
|
||||
var _allowAskForNoteAtEndOfConnection = false;
|
||||
var _preventSleepWhileConnected = true;
|
||||
@@ -141,8 +143,10 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
bind.mainGetBuildinOption(key: kOptionHideWebSocketSetting) == 'Y' ||
|
||||
isWeb;
|
||||
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
|
||||
_enableTcpPunch = mainGetLocalBoolOptionSync(kOptionEnableTcpPunch);
|
||||
_enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
_enableWebrtc = mainGetLocalBoolOptionSync(kOptionEnableWebrtc);
|
||||
_allowAskForNoteAtEndOfConnection =
|
||||
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
|
||||
_preventSleepWhileConnected =
|
||||
@@ -815,31 +819,65 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable TCP hole punching')),
|
||||
initialValue: _enableTcpPunch,
|
||||
onToggle: isOptionFixed(kOptionEnableTcpPunch)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableTcpPunch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableTcpPunch);
|
||||
setState(() {
|
||||
_enableTcpPunch = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable UDP hole punching')),
|
||||
initialValue: _enableUdpPunch,
|
||||
onToggle: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableUdpPunch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||
setState(() {
|
||||
_enableUdpPunch = newValue;
|
||||
});
|
||||
},
|
||||
onToggle: isOptionFixed(kOptionEnableUdpPunch)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableUdpPunch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||
setState(() {
|
||||
_enableUdpPunch = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable IPv6 P2P connection')),
|
||||
initialValue: _enableIpv6Punch,
|
||||
onToggle: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
setState(() {
|
||||
_enableIpv6Punch = newValue;
|
||||
});
|
||||
},
|
||||
onToggle: isOptionFixed(kOptionEnableIpv6Punch)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
setState(() {
|
||||
_enableIpv6Punch = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable WebRTC P2P connection')),
|
||||
initialValue: _enableWebrtc,
|
||||
onToggle: isOptionFixed(kOptionEnableWebrtc)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableWebrtc, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableWebrtc);
|
||||
setState(() {
|
||||
_enableWebrtc = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(translate('Language')),
|
||||
|
||||
@@ -3597,6 +3597,16 @@ class QualityMonitorModel with ChangeNotifier {
|
||||
bool get show => _show;
|
||||
QualityMonitorData get data => _data;
|
||||
|
||||
// Only a WebRTC session names its transport here: web has no session tab
|
||||
// to show it on, and WebRTC is the one path that can be direct or TURN.
|
||||
String? get webrtcTransport {
|
||||
final ffiModel = parent.target?.ffiModel;
|
||||
if (ffiModel == null) return null;
|
||||
final streamType = ffiModel.cachedPeerData.streamType;
|
||||
if (!streamType.startsWith('WebRTC')) return null;
|
||||
return ffiModel.direct == false ? '$streamType (TURN)' : streamType;
|
||||
}
|
||||
|
||||
checkShowQualityMonitor(SessionID sessionId) async {
|
||||
final show = await bind.sessionGetToggleOption(
|
||||
sessionId: sessionId, arg: 'show-quality-monitor') ==
|
||||
|
||||
Submodule libs/hbb_common updated: b2b1ac453d...9f67872f6d
@@ -12,7 +12,7 @@ build = "build.rs"
|
||||
brotli = "3.4"
|
||||
dirs = "5.0"
|
||||
md5 = "0.7"
|
||||
winapi = { version = "0.3", features = ["winbase"] }
|
||||
winapi = { version = "0.3", features = ["winbase", "libloaderapi"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.61", features = [
|
||||
|
||||
@@ -15,15 +15,29 @@ encoding = 'utf-8'
|
||||
# output: {path: (compressed_data, file_md5)}
|
||||
|
||||
|
||||
def generate_md5_table(folder: str, level) -> dict:
|
||||
def normalize(path: str) -> str:
|
||||
path = path.replace('\\', '/')
|
||||
while path.startswith('./'):
|
||||
path = path[2:]
|
||||
return path.lower()
|
||||
|
||||
|
||||
def generate_md5_table(folder: str, level, exclude: str = None) -> dict:
|
||||
res: dict = dict()
|
||||
curdir = os.curdir
|
||||
skip = normalize(exclude) if exclude else None
|
||||
excluded = False
|
||||
# os.curdir is the literal ".", so restoring it left us inside `folder`.
|
||||
curdir = os.getcwd()
|
||||
os.chdir(folder)
|
||||
for root, _, files in os.walk('.'):
|
||||
# remove ./
|
||||
for f in files:
|
||||
md5_generator = md5()
|
||||
full_path = os.path.join(root, f)
|
||||
if skip and normalize(full_path) == skip:
|
||||
print(f"Excluding {full_path}...")
|
||||
excluded = True
|
||||
continue
|
||||
print(f"Processing {full_path}...")
|
||||
f = open(full_path, "rb")
|
||||
content = f.read()
|
||||
@@ -33,11 +47,16 @@ def generate_md5_table(folder: str, level) -> dict:
|
||||
md5_code = md5_generator.hexdigest().encode(encoding=encoding)
|
||||
res[full_path] = (content_compressed, md5_code)
|
||||
os.chdir(curdir)
|
||||
if skip and not excluded:
|
||||
raise ValueError(f"excluded file was not found in {folder}: {exclude}")
|
||||
return res
|
||||
|
||||
|
||||
def write_package_metadata(md5_table: dict, output_folder: str, exe: str):
|
||||
output_path = os.path.join(output_folder, "data.bin")
|
||||
write_blob(md5_table, os.path.join(output_folder, "data.bin"), exe)
|
||||
|
||||
|
||||
def write_blob(md5_table: dict, output_path: str, exe: str):
|
||||
with open(output_path, "wb") as f:
|
||||
f.write("rustdesk".encode(encoding=encoding))
|
||||
for path in md5_table.keys():
|
||||
@@ -92,6 +111,14 @@ if __name__ == '__main__':
|
||||
help="the target used by cargo")
|
||||
parser.add_option("-l", "--level", dest="level", type="int",
|
||||
help="compression level, default is 11, highest", default=11)
|
||||
parser.add_option("--package", dest="package",
|
||||
help="write the per-customer blob to this path instead of "
|
||||
"data.bin, and skip the cargo build. Injected into the "
|
||||
"template's RDPKG resource so customizing needs no rebuild")
|
||||
parser.add_option("--exclude-exe", dest="exclude_exe", action="store_true",
|
||||
default=False,
|
||||
help="omit the executable from the blob, for a template whose "
|
||||
"executable ships in the package instead")
|
||||
(options, args) = parser.parse_args()
|
||||
folder = options.folder or './rustdesk'
|
||||
output_folder = os.path.abspath(options.output_folder or './')
|
||||
@@ -100,14 +127,29 @@ if __name__ == '__main__':
|
||||
options.executable = 'rustdesk.exe'
|
||||
if not options.executable.startswith(folder):
|
||||
options.executable = folder + '/' + options.executable
|
||||
# Note: the simple check `options.executable.startswith(folder)` is incorrect.
|
||||
# `python generate.py -f rustdesk -e rustdesk.exe` or `python generate.py -f rustdesk`
|
||||
# will result the print "Executable path: ..exe".
|
||||
# So we need to check if the executable is in the folder, and if so, concat again.
|
||||
if os.path.exists(os.path.join(folder, options.executable)):
|
||||
options.executable = os.path.join(folder, options.executable)
|
||||
folder_path = os.path.abspath(folder)
|
||||
exe: str = os.path.abspath(options.executable)
|
||||
if not exe.startswith(os.path.abspath(folder)):
|
||||
try:
|
||||
in_source_folder = os.path.commonpath([folder_path, exe]) == folder_path
|
||||
except ValueError:
|
||||
in_source_folder = False
|
||||
if not in_source_folder:
|
||||
print("The executable must locate in source folder")
|
||||
exit(-1)
|
||||
exe = '.' + exe[len(os.path.abspath(folder)):]
|
||||
exe = '.' + exe[len(folder_path):]
|
||||
print("Executable path: " + exe)
|
||||
print("Compression level: " + str(options.level))
|
||||
md5_table = generate_md5_table(folder, options.level)
|
||||
write_package_metadata(md5_table, output_folder, exe)
|
||||
write_app_metadata(output_folder)
|
||||
build_portable(output_folder, options.target)
|
||||
md5_table = generate_md5_table(
|
||||
folder, options.level, exe if options.exclude_exe else None)
|
||||
if options.package:
|
||||
write_blob(md5_table, os.path.abspath(options.package), exe)
|
||||
else:
|
||||
write_package_metadata(md5_table, output_folder, exe)
|
||||
write_app_metadata(output_folder)
|
||||
build_portable(output_folder, options.target)
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::{self},
|
||||
io::{Cursor, Read},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
// The generic payload, shared by every customer and compiled in once per release.
|
||||
#[cfg(windows)]
|
||||
const BIN_DATA: &[u8] = include_bytes!("../data.bin");
|
||||
#[cfg(not(windows))]
|
||||
const BIN_DATA: &[u8] = &[];
|
||||
|
||||
// The per-customer payload, injected into the RCDATA resource after the template
|
||||
// has been built, so that customizing a client needs no recompilation.
|
||||
#[cfg(windows)]
|
||||
const PACKAGE_RESOURCE_NAME: &str = "RDPKG";
|
||||
|
||||
// 4bytes
|
||||
const LENGTH: usize = 4;
|
||||
const IDENTIFIER: &[u8] = b"rustdesk";
|
||||
const IDENTIFIER_LENGTH: usize = 8;
|
||||
const MD5_LENGTH: usize = 32;
|
||||
const BUF_SIZE: usize = 4096;
|
||||
@@ -24,12 +31,172 @@ pub(crate) struct BinaryData {
|
||||
pub(crate) struct BinaryReader {
|
||||
pub files: Vec<BinaryData>,
|
||||
pub exe: String,
|
||||
// Paths supplied by the per-customer package. Recorded so that a file dropped
|
||||
// from a later package -- a logo the customer removed, say -- can be deleted
|
||||
// from an existing extraction, which the timestamp wipe no longer covers now
|
||||
// that the packer is built once per release rather than once per customer.
|
||||
pub package_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for BinaryReader {
|
||||
fn default() -> Self {
|
||||
let (files, exe) = BinaryReader::read();
|
||||
Self { files, exe }
|
||||
impl BinaryReader {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let package = read_package()?;
|
||||
let package_paths = package.0.iter().map(|f| f.path.clone()).collect();
|
||||
let (files, exe) = merge(read_embedded()?, package);
|
||||
Ok(Self {
|
||||
files,
|
||||
exe,
|
||||
package_paths,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Folds the per-customer package into the generic payload.
|
||||
fn merge(
|
||||
embedded: (Vec<BinaryData>, String),
|
||||
package: (Vec<BinaryData>, String),
|
||||
) -> (Vec<BinaryData>, String) {
|
||||
let (mut files, generic_exe) = embedded;
|
||||
let (package_files, package_exe) = package;
|
||||
|
||||
let exe = if package_exe.is_empty() {
|
||||
generic_exe.clone()
|
||||
} else {
|
||||
package_exe
|
||||
};
|
||||
|
||||
// The generic payload ships the executable under its stock name, the package
|
||||
// decides the final one. Rename on extraction so the process is always
|
||||
// `<appname>.exe`, which the app itself relies on to find its own sessions.
|
||||
if !generic_exe.is_empty() && normalize_path(&exe) != normalize_path(&generic_exe) {
|
||||
let generic_key = normalize_path(&generic_exe);
|
||||
for file in files.iter_mut() {
|
||||
if normalize_path(&file.path) == generic_key {
|
||||
file.path = exe.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-customer entries replace the generic ones they shadow.
|
||||
if !package_files.is_empty() {
|
||||
let overridden: HashSet<String> = package_files
|
||||
.iter()
|
||||
.map(|file| normalize_path(&file.path))
|
||||
.collect();
|
||||
files.retain(|file| !overridden.contains(&normalize_path(&file.path)));
|
||||
files.extend(package_files);
|
||||
}
|
||||
|
||||
(files, exe)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_path(path: &str) -> String {
|
||||
path.replace('\\', "/")
|
||||
.trim_start_matches("./")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn read_u32(blob: &[u8], at: usize) -> Option<u32> {
|
||||
let bytes = blob.get(at..at + LENGTH)?;
|
||||
Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
|
||||
// Returns the files and the executable to launch, or None if the blob is absent or malformed.
|
||||
fn parse(blob: &'static [u8]) -> Option<(Vec<BinaryData>, String)> {
|
||||
let mut base = 0usize;
|
||||
let mut parsed = Vec::new();
|
||||
if blob.get(base..base + IDENTIFIER_LENGTH)? != IDENTIFIER {
|
||||
return None;
|
||||
}
|
||||
base += IDENTIFIER_LENGTH;
|
||||
loop {
|
||||
if blob.get(base..base + IDENTIFIER_LENGTH)? == IDENTIFIER {
|
||||
base += IDENTIFIER_LENGTH;
|
||||
break;
|
||||
}
|
||||
let path_length = read_u32(blob, base)? as usize;
|
||||
base += LENGTH;
|
||||
let path = std::str::from_utf8(blob.get(base..base + path_length)?)
|
||||
.ok()?
|
||||
.to_owned();
|
||||
base += path_length;
|
||||
let file_length = read_u32(blob, base)? as usize;
|
||||
base += LENGTH;
|
||||
let raw = blob.get(base..base + file_length)?;
|
||||
base += file_length;
|
||||
let md5_code = blob.get(base..base + MD5_LENGTH)?;
|
||||
base += MD5_LENGTH;
|
||||
parsed.push(BinaryData {
|
||||
md5_code,
|
||||
raw,
|
||||
path,
|
||||
});
|
||||
}
|
||||
let executable = std::str::from_utf8(blob.get(base..)?).ok()?.to_owned();
|
||||
Some((parsed, executable))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_embedded() -> Result<(Vec<BinaryData>, String), String> {
|
||||
parse(BIN_DATA).ok_or_else(|| "bin file is not valid!".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn read_embedded() -> Result<(Vec<BinaryData>, String), String> {
|
||||
Ok(Default::default())
|
||||
}
|
||||
|
||||
fn parse_package_blob(blob: Option<&'static [u8]>) -> Result<(Vec<BinaryData>, String), String> {
|
||||
let Some(blob) = blob else {
|
||||
return Ok(Default::default());
|
||||
};
|
||||
let package = parse(blob).ok_or_else(|| "RDPKG resource is invalid".to_owned())?;
|
||||
if package.1.trim().is_empty() {
|
||||
return Err("RDPKG resource has no executable".to_owned());
|
||||
}
|
||||
Ok(package)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_package() -> Result<(Vec<BinaryData>, String), String> {
|
||||
parse_package_blob(read_resource(PACKAGE_RESOURCE_NAME))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn read_package() -> Result<(Vec<BinaryData>, String), String> {
|
||||
Ok(Default::default())
|
||||
}
|
||||
|
||||
// Reads an RCDATA resource out of the running image. Resources live in the mapped
|
||||
// image for the lifetime of the process, so the slice is genuinely 'static and no
|
||||
// copy is needed.
|
||||
#[cfg(windows)]
|
||||
fn read_resource(name: &str) -> Option<&'static [u8]> {
|
||||
use std::ptr::null_mut;
|
||||
use winapi::um::libloaderapi::{FindResourceW, LoadResource, LockResource, SizeofResource};
|
||||
|
||||
// MAKEINTRESOURCEW(10), avoids depending on the winuser feature for RT_RCDATA.
|
||||
const RT_RCDATA: *const u16 = 10 as _;
|
||||
|
||||
let name: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
unsafe {
|
||||
let info = FindResourceW(null_mut(), name.as_ptr(), RT_RCDATA);
|
||||
if info.is_null() {
|
||||
return None;
|
||||
}
|
||||
let size = SizeofResource(null_mut(), info) as usize;
|
||||
if size == 0 {
|
||||
return None;
|
||||
}
|
||||
let handle = LoadResource(null_mut(), info);
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
let data = LockResource(handle) as *const u8;
|
||||
if data.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(std::slice::from_raw_parts(data, size))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,59 +235,6 @@ impl BinaryData {
|
||||
}
|
||||
|
||||
impl BinaryReader {
|
||||
fn read() -> (Vec<BinaryData>, String) {
|
||||
let mut base: usize = 0;
|
||||
let mut parsed = vec![];
|
||||
assert!(BIN_DATA.len() > IDENTIFIER_LENGTH, "bin data invalid!");
|
||||
let mut iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]);
|
||||
if iden != "rustdesk" {
|
||||
panic!("bin file is not valid!");
|
||||
}
|
||||
base += IDENTIFIER_LENGTH;
|
||||
loop {
|
||||
iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]);
|
||||
if iden == "rustdesk" {
|
||||
base += IDENTIFIER_LENGTH;
|
||||
break;
|
||||
}
|
||||
// start reading
|
||||
let mut offset = 0;
|
||||
let path_length = u32::from_be_bytes([
|
||||
BIN_DATA[base + offset],
|
||||
BIN_DATA[base + offset + 1],
|
||||
BIN_DATA[base + offset + 2],
|
||||
BIN_DATA[base + offset + 3],
|
||||
]) as usize;
|
||||
offset += LENGTH;
|
||||
let path =
|
||||
String::from_utf8_lossy(&BIN_DATA[base + offset..base + offset + path_length])
|
||||
.to_string();
|
||||
offset += path_length;
|
||||
// file sz
|
||||
let file_length = u32::from_be_bytes([
|
||||
BIN_DATA[base + offset],
|
||||
BIN_DATA[base + offset + 1],
|
||||
BIN_DATA[base + offset + 2],
|
||||
BIN_DATA[base + offset + 3],
|
||||
]) as usize;
|
||||
offset += LENGTH;
|
||||
let raw = &BIN_DATA[base + offset..base + offset + file_length];
|
||||
offset += file_length;
|
||||
// md5
|
||||
let md5 = &BIN_DATA[base + offset..base + offset + MD5_LENGTH];
|
||||
offset += MD5_LENGTH;
|
||||
parsed.push(BinaryData {
|
||||
md5_code: md5,
|
||||
raw: raw,
|
||||
path: path,
|
||||
});
|
||||
base += offset;
|
||||
}
|
||||
// executable
|
||||
let executable = String::from_utf8_lossy(&BIN_DATA[base..]).to_string();
|
||||
(parsed, executable)
|
||||
}
|
||||
|
||||
#[cfg(linux)]
|
||||
pub fn configure_permission(&self, prefix: &Path) {
|
||||
use std::os::unix::prelude::PermissionsExt;
|
||||
@@ -137,3 +251,155 @@ impl BinaryReader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Builds a blob in the same layout generate.py writes, so these tests pin the
|
||||
// cross-language format contract as well as the merge rules.
|
||||
fn blob(files: &[(&str, &[u8])], exe: &str) -> &'static [u8] {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(IDENTIFIER);
|
||||
for (path, data) in files {
|
||||
out.extend_from_slice(&(path.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(path.as_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(&[b'a'; MD5_LENGTH]);
|
||||
}
|
||||
out.extend_from_slice(IDENTIFIER);
|
||||
out.extend_from_slice(exe.as_bytes());
|
||||
Box::leak(out.into_boxed_slice())
|
||||
}
|
||||
|
||||
fn entry<'a>(files: &'a [BinaryData], path: &str) -> Option<&'a BinaryData> {
|
||||
files
|
||||
.iter()
|
||||
.find(|file| normalize_path(&file.path) == normalize_path(path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_generate_py_layout() {
|
||||
let (files, exe) = parse(blob(
|
||||
&[("./rustdesk.exe", b"app"), ("./custom.txt", b"cfg")],
|
||||
"./rustdesk.exe",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(exe, "./rustdesk.exe");
|
||||
assert_eq!(files.len(), 2);
|
||||
assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_blobs() {
|
||||
assert!(parse(b"".as_slice()).is_none());
|
||||
assert!(parse(b"notrustd".as_slice()).is_none());
|
||||
// Truncated mid-record rather than panicking on a slice out of range.
|
||||
assert!(parse(b"rustdesk\x00\x00\x00\x40partial".as_slice()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinguishes_an_absent_package_from_a_malformed_one() {
|
||||
assert!(parse_package_blob(None).unwrap().0.is_empty());
|
||||
assert!(parse_package_blob(Some(b"damaged")).is_err());
|
||||
assert!(parse_package_blob(Some(blob(&[("./custom.txt", b"cfg")], ""))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_package_the_stock_payload_is_untouched() {
|
||||
let embedded = parse(blob(&[("./rustdesk.exe", b"app")], "./rustdesk.exe")).unwrap();
|
||||
let (files, exe) = merge(embedded, Default::default());
|
||||
assert_eq!(exe, "./rustdesk.exe");
|
||||
assert!(entry(&files, "./rustdesk.exe").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_the_stock_executable_to_the_package_name() {
|
||||
// x86: the big executable stays in the generic payload and only gets renamed.
|
||||
let embedded = parse(blob(
|
||||
&[("./rustdesk.exe", b"app"), ("./sciter.dll", b"dll")],
|
||||
"./rustdesk.exe",
|
||||
))
|
||||
.unwrap();
|
||||
let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap();
|
||||
|
||||
let (files, exe) = merge(embedded, package);
|
||||
|
||||
assert_eq!(exe, "./acme.exe");
|
||||
assert!(entry(&files, "./acme.exe").is_some());
|
||||
assert!(entry(&files, "./rustdesk.exe").is_none());
|
||||
// Untouched neighbours survive.
|
||||
assert_eq!(entry(&files, "./sciter.dll").unwrap().raw, b"dll");
|
||||
assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_entries_win_over_the_generic_payload() {
|
||||
// x64: the customized executable and icons ship in the package instead.
|
||||
let embedded = parse(blob(
|
||||
&[
|
||||
("./data/flutter_assets/assets/icon.ico", b"stock-icon"),
|
||||
("./librustdesk.dll", b"core"),
|
||||
],
|
||||
"./rustdesk.exe",
|
||||
))
|
||||
.unwrap();
|
||||
let package = parse(blob(
|
||||
&[
|
||||
("./acme.exe", b"branded"),
|
||||
("./data/flutter_assets/assets/icon.ico", b"acme-icon"),
|
||||
],
|
||||
"./acme.exe",
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let (files, exe) = merge(embedded, package);
|
||||
|
||||
assert_eq!(exe, "./acme.exe");
|
||||
assert_eq!(
|
||||
entry(&files, "./data/flutter_assets/assets/icon.ico")
|
||||
.unwrap()
|
||||
.raw,
|
||||
b"acme-icon"
|
||||
);
|
||||
assert_eq!(
|
||||
files
|
||||
.iter()
|
||||
.filter(|f| normalize_path(&f.path) == "data/flutter_assets/assets/icon.ico")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(entry(&files, "./librustdesk.dll").unwrap().raw, b"core");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_paths_are_recorded_for_the_dropped_file_sweep() {
|
||||
let package = parse(blob(
|
||||
&[("./custom.txt", b"cfg"), ("./data/logo.png", b"img")],
|
||||
"./acme.exe",
|
||||
))
|
||||
.unwrap();
|
||||
let mut paths: Vec<String> = package.0.iter().map(|f| f.path.clone()).collect();
|
||||
paths.sort();
|
||||
assert_eq!(paths, vec!["./custom.txt", "./data/logo.png"]);
|
||||
|
||||
// Merging must not disturb them: the generic payload contributes none.
|
||||
let embedded = parse(blob(&[("./librustdesk.dll", b"core")], "./rustdesk.exe")).unwrap();
|
||||
let (files, _) = merge(embedded, package);
|
||||
assert!(entry(&files, "./data/logo.png").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_paths_across_separator_styles() {
|
||||
// generate.py emits backslashes when it runs on Windows.
|
||||
let embedded = parse(blob(&[(".\\rustdesk.exe", b"app")], ".\\rustdesk.exe")).unwrap();
|
||||
let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap();
|
||||
|
||||
let (files, exe) = merge(embedded, package);
|
||||
|
||||
assert_eq!(exe, "./acme.exe");
|
||||
assert!(entry(&files, "./acme.exe").is_some());
|
||||
assert!(entry(&files, ".\\rustdesk.exe").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
use bin_reader::BinaryReader;
|
||||
use bin_reader::{normalize_path, BinaryReader};
|
||||
|
||||
pub mod bin_reader;
|
||||
#[cfg(windows)]
|
||||
@@ -17,11 +17,24 @@ const APP_METADATA: &[u8] = include_bytes!("../app_metadata.toml");
|
||||
const APP_METADATA: &[u8] = &[];
|
||||
const APP_METADATA_CONFIG: &str = "meta.toml";
|
||||
const META_LINE_PREFIX_TIMESTAMP: &str = "timestamp = ";
|
||||
const META_LINE_PREFIX_FILE: &str = "file = ";
|
||||
const APP_PREFIX: &str = "rustdesk";
|
||||
const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME";
|
||||
#[cfg(windows)]
|
||||
const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW";
|
||||
|
||||
// The extraction directory follows whatever executable the payload asks for, so a
|
||||
// custom client gets its own directory instead of sharing RustDesk's. Falls back to
|
||||
// APP_PREFIX when no package is injected, which keeps stock builds unchanged.
|
||||
fn app_dir_name(exe: &str) -> String {
|
||||
Path::new(&exe.replace('\\', "/"))
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(|stem| stem.trim().to_lowercase())
|
||||
.filter(|stem| !stem.is_empty())
|
||||
.unwrap_or_else(|| APP_PREFIX.to_owned())
|
||||
}
|
||||
|
||||
fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
|
||||
let Ok(app_metadata) = std::str::from_utf8(APP_METADATA) else {
|
||||
return true;
|
||||
@@ -50,13 +63,93 @@ fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn write_meta(dir: &Path, ts: u64) {
|
||||
fn write_meta(dir: &Path, ts: u64, package_paths: &[String]) {
|
||||
let meta_file = dir.join(APP_METADATA_CONFIG);
|
||||
if ts != 0 {
|
||||
let content = format!("{}{}", META_LINE_PREFIX_TIMESTAMP, ts);
|
||||
// Ignore is ok here
|
||||
let _ = std::fs::write(meta_file, content);
|
||||
let mut content = format!("{}{}\n", META_LINE_PREFIX_TIMESTAMP, ts);
|
||||
for path in package_paths {
|
||||
content.push_str(&format!("{}{}\n", META_LINE_PREFIX_FILE, path));
|
||||
}
|
||||
// Ignore is ok here
|
||||
let _ = std::fs::write(meta_file, content);
|
||||
}
|
||||
|
||||
fn previous_package_files(dir: &Path) -> Vec<String> {
|
||||
let Ok(content) = std::fs::read_to_string(dir.join(APP_METADATA_CONFIG)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix(META_LINE_PREFIX_FILE))
|
||||
.map(|path| path.trim().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// meta.toml is plain text in a user-writable directory, and it now drives deletion,
|
||||
// so the path is rebuilt from plain components rather than joined as written. A
|
||||
// prefix, root or parent component would otherwise escape the extraction directory:
|
||||
// Path::join replaces the base entirely when given an absolute path.
|
||||
fn resolve_within(dir: &Path, relative: &str) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let mut path = dir.to_path_buf();
|
||||
let mut any = false;
|
||||
for component in Path::new(&relative.replace('\\', "/")).components() {
|
||||
match component {
|
||||
Component::Normal(part) => {
|
||||
// A drive-relative name like "C:x" parses as Normal, and only a
|
||||
// Windows host would classify "C:/..." as a Prefix, so the colon is
|
||||
// rejected outright rather than relying on the host's parser.
|
||||
if part.to_string_lossy().contains(':') {
|
||||
return None;
|
||||
}
|
||||
path.push(part);
|
||||
any = true;
|
||||
}
|
||||
Component::CurDir => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
if any {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// A customer who drops a branding asset gets a package without it, and the file
|
||||
// would otherwise linger in an existing extraction and keep being used. The wipe
|
||||
// cannot cover this: it is keyed on the packer's build timestamp, which is now the
|
||||
// same for every customer of a release.
|
||||
fn remove_dropped_package_files_with<F>(
|
||||
dir: &Path,
|
||||
current: &[String],
|
||||
mut remove_file: F,
|
||||
) -> Vec<String>
|
||||
where
|
||||
F: FnMut(&Path) -> std::io::Result<()>,
|
||||
{
|
||||
let keep: std::collections::HashSet<String> =
|
||||
current.iter().map(|p| normalize_path(p)).collect();
|
||||
let mut failed = Vec::new();
|
||||
for previous in previous_package_files(dir) {
|
||||
if keep.contains(&normalize_path(&previous)) {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = resolve_within(dir, &previous) else {
|
||||
continue;
|
||||
};
|
||||
if path.is_file() {
|
||||
println!("removing dropped {}", previous);
|
||||
if let Err(error) = remove_file(&path) {
|
||||
eprintln!("failed to remove dropped {}: {}", previous, error);
|
||||
failed.push(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
fn remove_dropped_package_files(dir: &Path, current: &[String]) -> Vec<String> {
|
||||
remove_dropped_package_files_with(dir, current, |path| std::fs::remove_file(path))
|
||||
}
|
||||
|
||||
fn setup(
|
||||
@@ -71,7 +164,7 @@ fn setup(
|
||||
} else {
|
||||
// home dir
|
||||
if let Some(dir) = dirs::data_local_dir() {
|
||||
dir.join(APP_PREFIX)
|
||||
dir.join(app_dir_name(&reader.exe))
|
||||
} else {
|
||||
eprintln!("not found data local dir");
|
||||
return None;
|
||||
@@ -87,10 +180,12 @@ fn setup(
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
let mut metadata_paths = reader.package_paths.clone();
|
||||
metadata_paths.extend(remove_dropped_package_files(&dir, &reader.package_paths));
|
||||
for file in reader.files.iter() {
|
||||
file.write_to_file(&dir);
|
||||
}
|
||||
write_meta(&dir, ts);
|
||||
write_meta(&dir, ts, &metadata_paths);
|
||||
#[cfg(windows)]
|
||||
win::copy_runtime_broker(&dir);
|
||||
#[cfg(linux)]
|
||||
@@ -174,7 +269,7 @@ fn execute(path: PathBuf, args: Vec<String>, _ui: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
fn main() -> Result<(), String> {
|
||||
let mut args = Vec::new();
|
||||
let mut arg_exe = Default::default();
|
||||
let mut i = 0;
|
||||
@@ -193,7 +288,7 @@ fn main() {
|
||||
let quick_support = false;
|
||||
|
||||
let mut ui = false;
|
||||
let reader = BinaryReader::default();
|
||||
let reader = BinaryReader::new()?;
|
||||
if let Some(exe) = setup(
|
||||
reader,
|
||||
None,
|
||||
@@ -208,6 +303,7 @@ fn main() {
|
||||
}
|
||||
execute(exe, args, ui);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -246,3 +342,27 @@ mod win {
|
||||
exe.contains("-qs-") || exe.contains("-qs.exe") || exe.contains("_qs.exe")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod meta_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_within_rejects_paths_that_escape() {
|
||||
let base = Path::new("/base");
|
||||
assert_eq!(
|
||||
resolve_within(base, "./data/logo.png"),
|
||||
Some(base.join("data").join("logo.png"))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_within(base, ".\\data\\logo.png"),
|
||||
Some(base.join("data").join("logo.png"))
|
||||
);
|
||||
// meta.toml is user-writable, so these must not reach remove_file.
|
||||
assert_eq!(resolve_within(base, "../../etc/passwd"), None);
|
||||
assert_eq!(resolve_within(base, "/etc/passwd"), None);
|
||||
assert_eq!(resolve_within(base, "C:\\Windows\\System32\\x.dll"), None);
|
||||
assert_eq!(resolve_within(base, "."), None);
|
||||
assert_eq!(resolve_within(base, ""), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ serde = {version="1.0", features=["derive"]}
|
||||
[dependencies.winapi]
|
||||
version = "0.3"
|
||||
default-features = true
|
||||
features = ["dxgi", "dxgi1_2", "dxgi1_5", "dxgi1_6", "d3d11", "winuser", "winerror", "errhandlingapi", "libloaderapi"]
|
||||
features = ["dxgi", "dxgi1_2", "dxgi1_5", "d3d11", "winuser", "winerror", "errhandlingapi", "libloaderapi"]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
block = "0.1"
|
||||
|
||||
@@ -143,7 +143,7 @@ fn test_vpx(
|
||||
println!(
|
||||
"{:?} encode: {:?}, {} byte",
|
||||
codec_id,
|
||||
time_sum / yuv_count as _,
|
||||
time_sum / yuv_count as u32,
|
||||
size / yuv_count
|
||||
);
|
||||
|
||||
@@ -156,7 +156,7 @@ fn test_vpx(
|
||||
println!(
|
||||
"{:?} decode: {:?}",
|
||||
codec_id,
|
||||
start.elapsed() / yuv_count as _
|
||||
start.elapsed() / yuv_count as u32
|
||||
);
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ fn test_av1(
|
||||
assert_eq!(av1s.len(), yuv_count);
|
||||
println!(
|
||||
"AV1 encode: {:?}, {} byte",
|
||||
time_sum / yuv_count as _,
|
||||
time_sum / yuv_count as u32,
|
||||
size / yuv_count
|
||||
);
|
||||
let mut decoder = AomDecoder::new().unwrap();
|
||||
@@ -221,7 +221,7 @@ fn test_av1(
|
||||
let _ = decoder.decode(&av1);
|
||||
let _ = decoder.flush();
|
||||
}
|
||||
println!("AV1 decode: {:?}", start.elapsed() / yuv_count as _);
|
||||
println!("AV1 decode: {:?}", start.elapsed() / yuv_count as u32);
|
||||
}
|
||||
|
||||
#[cfg(feature = "hwcodec")]
|
||||
|
||||
@@ -132,7 +132,15 @@ impl Display {
|
||||
.map(Display)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let displays_dxgi = Self::all_().unwrap_or(Default::default());
|
||||
let mut displays_dxgi = match Self::all_() {
|
||||
Ok(displays) => displays,
|
||||
Err(e) => {
|
||||
hbb_common::log::error!("DXGI display enumeration failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
// Win+P "Show only on 1/2" still enumerates detached DXGI outputs.
|
||||
displays_dxgi.retain(|d| d.is_online() && d.width() > 0 && d.height() > 0);
|
||||
|
||||
// Return gdi displays if dxgi is not supported
|
||||
if displays_dxgi.is_empty() {
|
||||
@@ -155,7 +163,6 @@ impl Display {
|
||||
}
|
||||
|
||||
// Reorder displays from dxgi
|
||||
let mut displays_dxgi = displays_dxgi;
|
||||
let mut displays_dxgi_ordered = Vec::new();
|
||||
for name in names_gdi.iter() {
|
||||
let pos = match displays_dxgi.iter().position(|d| d.name() == *name) {
|
||||
@@ -176,11 +183,11 @@ impl Display {
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.0.width() as usize
|
||||
self.0.width().max(0) as usize
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.0.height() as usize
|
||||
self.0.height().max(0) as usize
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
@@ -201,7 +208,8 @@ impl Display {
|
||||
|
||||
pub fn is_primary(&self) -> bool {
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodea
|
||||
self.origin() == (0, 0)
|
||||
// Detached outputs can still report origin (0,0) with a zero size.
|
||||
self.origin() == (0, 0) && self.width() > 0 && self.height() > 0
|
||||
}
|
||||
|
||||
#[cfg(feature = "vram")]
|
||||
|
||||
@@ -1,629 +0,0 @@
|
||||
//! HDR desktop -> SDR normalization for Desktop Duplication frames.
|
||||
//!
|
||||
//! With HDR enabled Windows composes the desktop as linear scRGB in
|
||||
//! R16G16B16A16_FLOAT, and SDR "white" sits at the user's SDR content
|
||||
//! brightness (DISPLAYCONFIG_SDR_WHITE_LEVEL) rather than at 1.0. The legacy
|
||||
//! DuplicateOutput converts that to BGRA8 by clipping, which is the washed-out
|
||||
//! picture reported for HDR hosts. This pass divides by the SDR white level,
|
||||
//! clamps, and applies the sRGB transfer, so SDR content comes out exactly as
|
||||
//! it would from an SDR desktop.
|
||||
//!
|
||||
//! It is a normalization, not a tone map: anything brighter than SDR white
|
||||
//! (HDR video, HDR games) clips to white on the SDR viewer, where the local
|
||||
//! HDR display would show it brighter than white. A roll-off would have to
|
||||
//! move SDR white below 1.0 to make headroom, trading the accuracy of the SDR
|
||||
//! content this pass exists for, so it is deliberately not done.
|
||||
//!
|
||||
//! Windows 11 22H2 also composes Advanced Color SDR (WCG) desktops in FP16,
|
||||
//! but there 1.0 is the display's reference white rather than 80 nits and no
|
||||
//! SDR white level applies. IDXGIOutput6 tells the two apart, and for a
|
||||
//! non-HDR output the pass only applies the scRGB -> sRGB transfer.
|
||||
//!
|
||||
//! The conversion is automatic and stays on the controlled side on purpose:
|
||||
//! the controller renders through Flutter external textures, which are 8-bit
|
||||
//! on every desktop platform, so there is nothing to gain from sending HDR.
|
||||
//! Real HDR pass-through, if the renderer ever supports it, should follow the
|
||||
//! Sunshine/Moonlight pattern instead: an `hdr` capability bit advertised by
|
||||
//! the controller behind an explicit user toggle, negotiated like i444.
|
||||
|
||||
use super::ComPtr;
|
||||
use hbb_common::log;
|
||||
use std::{
|
||||
io, mem, ptr,
|
||||
sync::{atomic::AtomicBool, OnceLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use winapi::{
|
||||
ctypes::c_void,
|
||||
shared::{
|
||||
basetsd::SIZE_T,
|
||||
dxgi::{CreateDXGIFactory1, IDXGIFactory1, IID_IDXGIFactory1, DXGI_OUTPUT_DESC},
|
||||
dxgi1_2::IDXGIOutput1,
|
||||
dxgi1_6::{IDXGIOutput6, IID_IDXGIOutput6, DXGI_OUTPUT_DESC1},
|
||||
dxgiformat::DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
dxgitype::{DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_SAMPLE_DESC},
|
||||
minwindef::{FALSE, LPCVOID, UINT, ULONG},
|
||||
ntdef::{LONG, LPCSTR, WCHAR},
|
||||
winerror::S_OK,
|
||||
},
|
||||
um::{
|
||||
d3d11::*,
|
||||
d3dcommon::{ID3DBlob, ID3DInclude, D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, D3D_SHADER_MACRO},
|
||||
libloaderapi::{GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32},
|
||||
unknwnbase::IUnknown,
|
||||
wingdi::{
|
||||
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_HEADER,
|
||||
DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
|
||||
DISPLAYCONFIG_TOPOLOGY_ID,
|
||||
},
|
||||
winnt::HRESULT,
|
||||
},
|
||||
};
|
||||
|
||||
/// Set once the tone-map can never work in this process (no d3dcompiler, the
|
||||
/// shaders do not compile). Capturers then stop asking DXGI for float frames.
|
||||
/// Device-specific failures are not recorded here; the capturer that hit one
|
||||
/// re-duplicates without the tone-map on its own.
|
||||
pub static UNAVAILABLE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Failures no capturer on this machine can recover from, as opposed to
|
||||
/// device-specific ones that a recreated capturer may not hit again.
|
||||
pub fn is_permanent(err: &io::Error) -> bool {
|
||||
err.kind() == io::ErrorKind::Unsupported
|
||||
}
|
||||
|
||||
const VS_SRC: &str = "\
|
||||
float4 main(uint id : SV_VertexID) : SV_Position {
|
||||
float2 uv = float2((id << 1) & 2, id & 2);
|
||||
return float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0);
|
||||
}";
|
||||
|
||||
const PS_SRC: &str = "\
|
||||
Texture2D<float4> src : register(t0);
|
||||
cbuffer Params : register(b0) { float inv_sdr_white; float3 pad; };
|
||||
float4 main(float4 pos : SV_Position) : SV_Target {
|
||||
float3 lin = saturate(src.Load(int3(pos.xy, 0)).rgb * inv_sdr_white);
|
||||
float3 lo = lin * 12.92;
|
||||
float3 hi = 1.055 * pow(lin, 1.0 / 2.4) - 0.055;
|
||||
return float4(lerp(hi, lo, step(lin, 0.0031308)), 1.0);
|
||||
}";
|
||||
|
||||
const OUTPUT_STATE_REFRESH: Duration = Duration::from_secs(1);
|
||||
|
||||
pub struct HdrToSdr {
|
||||
device: ComPtr<ID3D11Device>,
|
||||
context: ComPtr<ID3D11DeviceContext>,
|
||||
vs: ComPtr<ID3D11VertexShader>,
|
||||
ps: ComPtr<ID3D11PixelShader>,
|
||||
params: ComPtr<ID3D11Buffer>,
|
||||
target: ComPtr<ID3D11Texture2D>,
|
||||
rtv: ComPtr<ID3D11RenderTargetView>,
|
||||
srv: ComPtr<ID3D11ShaderResourceView>,
|
||||
// Texture `srv` was created for. The view keeps it alive, so the address
|
||||
// cannot be recycled behind our back.
|
||||
srv_source: *mut ID3D11Texture2D,
|
||||
width: u32,
|
||||
height: u32,
|
||||
device_name: [WCHAR; 32],
|
||||
// Advanced Color state is read from `output6`, which is re-enumerated from a
|
||||
// fresh factory whenever `factory` stops being current.
|
||||
factory: ComPtr<IDXGIFactory1>,
|
||||
output6: ComPtr<IDXGIOutput6>,
|
||||
is_hdr: bool,
|
||||
// DISPLAYCONFIG units (1000 == 80 nits == scRGB 1.0). `None` when it could
|
||||
// not be read, in which case 80 nits is assumed until it can.
|
||||
sdr_white_level: Option<u32>,
|
||||
queried_at: Instant,
|
||||
}
|
||||
|
||||
impl HdrToSdr {
|
||||
pub fn new(
|
||||
device: *mut ID3D11Device,
|
||||
context: *mut ID3D11DeviceContext,
|
||||
output: *mut IDXGIOutput1,
|
||||
device_name: &[WCHAR; 32],
|
||||
) -> io::Result<Self> {
|
||||
unsafe {
|
||||
if device.is_null() || context.is_null() {
|
||||
return Err(other("no d3d11 device"));
|
||||
}
|
||||
(*device).AddRef();
|
||||
let device = ComPtr(device);
|
||||
(*context).AddRef();
|
||||
let context = ComPtr(context);
|
||||
|
||||
let compile = load_d3d_compile()?;
|
||||
let vs_code = compile_shader(compile, VS_SRC, b"vs_4_0\0")?;
|
||||
let ps_code = compile_shader(compile, PS_SRC, b"ps_4_0\0")?;
|
||||
let mut vs = ptr::null_mut();
|
||||
check(
|
||||
(*device.0).CreateVertexShader(
|
||||
(*vs_code.0).GetBufferPointer(),
|
||||
(*vs_code.0).GetBufferSize(),
|
||||
ptr::null_mut(),
|
||||
&mut vs,
|
||||
),
|
||||
"CreateVertexShader",
|
||||
)?;
|
||||
let vs = ComPtr(vs);
|
||||
let mut ps = ptr::null_mut();
|
||||
check(
|
||||
(*device.0).CreatePixelShader(
|
||||
(*ps_code.0).GetBufferPointer(),
|
||||
(*ps_code.0).GetBufferSize(),
|
||||
ptr::null_mut(),
|
||||
&mut ps,
|
||||
),
|
||||
"CreatePixelShader",
|
||||
)?;
|
||||
let ps = ComPtr(ps);
|
||||
|
||||
// Not found leaves the factory null, so the first refresh enumerates
|
||||
// again instead of trusting the capturer's possibly stale output.
|
||||
let (factory, mut output6) = enumerate_output6(device_name);
|
||||
if output6.is_null() {
|
||||
output6 = query_output6(output as *mut IUnknown);
|
||||
}
|
||||
// Float frames are only requested where IDXGIOutput6 exists, so an
|
||||
// unreadable description still comes from an HDR-capable stack.
|
||||
let is_hdr = output_is_hdr(output6.0).unwrap_or(true);
|
||||
let sdr_white_level = if is_hdr {
|
||||
query_sdr_white_level(device_name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if is_hdr && sdr_white_level.is_none() {
|
||||
log::warn!(
|
||||
"HDR output but the SDR white level cannot be read (needs Windows 10 1709+), \
|
||||
assuming 80 nits until it can"
|
||||
);
|
||||
}
|
||||
let init = params_data(sdr_white_level);
|
||||
let desc = D3D11_BUFFER_DESC {
|
||||
ByteWidth: mem::size_of_val(&init) as _,
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_CONSTANT_BUFFER,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
StructureByteStride: 0,
|
||||
};
|
||||
let data = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: init.as_ptr() as _,
|
||||
SysMemPitch: 0,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut params = ptr::null_mut();
|
||||
check(
|
||||
(*device.0).CreateBuffer(&desc, &data, &mut params),
|
||||
"CreateBuffer",
|
||||
)?;
|
||||
let params = ComPtr(params);
|
||||
log::info!(
|
||||
"scRGB desktop conversion ready, hdr {is_hdr}, sdr white level {sdr_white_level:?}"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
context,
|
||||
vs,
|
||||
ps,
|
||||
params,
|
||||
target: ComPtr(ptr::null_mut()),
|
||||
rtv: ComPtr(ptr::null_mut()),
|
||||
srv: ComPtr(ptr::null_mut()),
|
||||
srv_source: ptr::null_mut(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
device_name: *device_name,
|
||||
factory,
|
||||
output6,
|
||||
is_hdr,
|
||||
sdr_white_level,
|
||||
queried_at: Instant::now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders `source` (R16G16B16A16_FLOAT) into an owned B8G8R8A8_UNORM
|
||||
/// texture of the same size and returns it. The texture stays valid until
|
||||
/// the next call.
|
||||
pub fn convert(
|
||||
&mut self,
|
||||
source: *mut ID3D11Texture2D,
|
||||
desc: &D3D11_TEXTURE2D_DESC,
|
||||
) -> io::Result<*mut ID3D11Texture2D> {
|
||||
unsafe {
|
||||
self.refresh_output_state();
|
||||
self.ensure_target(desc.Width, desc.Height)?;
|
||||
self.ensure_source_view(source)?;
|
||||
|
||||
let ctx = self.context.0;
|
||||
let rtv = self.rtv.0;
|
||||
let srv = self.srv.0;
|
||||
let params = self.params.0;
|
||||
let viewport = D3D11_VIEWPORT {
|
||||
TopLeftX: 0.0,
|
||||
TopLeftY: 0.0,
|
||||
Width: self.width as f32,
|
||||
Height: self.height as f32,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
};
|
||||
(*ctx).OMSetRenderTargets(1, &rtv, ptr::null_mut());
|
||||
(*ctx).OMSetBlendState(ptr::null_mut(), &[0.0; 4], 0xffff_ffff);
|
||||
(*ctx).OMSetDepthStencilState(ptr::null_mut(), 0);
|
||||
(*ctx).RSSetState(ptr::null_mut());
|
||||
(*ctx).RSSetViewports(1, &viewport);
|
||||
(*ctx).IASetInputLayout(ptr::null_mut());
|
||||
(*ctx).IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||
(*ctx).VSSetShader(self.vs.0, ptr::null(), 0);
|
||||
(*ctx).PSSetShader(self.ps.0, ptr::null(), 0);
|
||||
(*ctx).PSSetConstantBuffers(0, 1, ¶ms);
|
||||
(*ctx).PSSetShaderResources(0, 1, &srv);
|
||||
(*ctx).Draw(3, 0);
|
||||
// Unbind so the next frame's copy and the encoder never see the
|
||||
// target as a live render target or the desktop image as a bound
|
||||
// shader input.
|
||||
let no_srv: *mut ID3D11ShaderResourceView = ptr::null_mut();
|
||||
(*ctx).PSSetShaderResources(0, 1, &no_srv);
|
||||
(*ctx).OMSetRenderTargets(0, ptr::null(), ptr::null_mut());
|
||||
Ok(self.target.0)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn ensure_target(&mut self, width: u32, height: u32) -> io::Result<()> {
|
||||
if !self.target.is_null() && self.width == width && self.height == height {
|
||||
return Ok(());
|
||||
}
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: D3D11_RESOURCE_MISC_SHARED,
|
||||
};
|
||||
let mut target = ptr::null_mut();
|
||||
check(
|
||||
(*self.device.0).CreateTexture2D(&desc, ptr::null(), &mut target),
|
||||
"CreateTexture2D",
|
||||
)?;
|
||||
let target = ComPtr(target);
|
||||
let mut rtv = ptr::null_mut();
|
||||
check(
|
||||
(*self.device.0).CreateRenderTargetView(target.0 as *mut _, ptr::null(), &mut rtv),
|
||||
"CreateRenderTargetView",
|
||||
)?;
|
||||
self.rtv = ComPtr(rtv);
|
||||
self.target = target;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe fn ensure_source_view(&mut self, source: *mut ID3D11Texture2D) -> io::Result<()> {
|
||||
if !self.srv.is_null() && self.srv_source == source {
|
||||
return Ok(());
|
||||
}
|
||||
let mut srv = ptr::null_mut();
|
||||
check(
|
||||
(*self.device.0).CreateShaderResourceView(source as *mut _, ptr::null(), &mut srv),
|
||||
"CreateShaderResourceView",
|
||||
)?;
|
||||
self.srv = ComPtr(srv);
|
||||
self.srv_source = source;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Advanced Color state is dynamic: HDR can be switched on or off, or a WCG
|
||||
// desktop can turn into an HDR one, without the duplication being lost. An
|
||||
// output's description is a snapshot, so once the factory is no longer
|
||||
// current a new factory and output are needed to see the new state, as the
|
||||
// GetDesc1 docs require.
|
||||
unsafe fn refresh_output_state(&mut self) {
|
||||
if self.queried_at.elapsed() < OUTPUT_STATE_REFRESH {
|
||||
return;
|
||||
}
|
||||
self.queried_at = Instant::now();
|
||||
if self.factory.is_null() || (*self.factory.0).IsCurrent() == FALSE {
|
||||
let (factory, output6) = enumerate_output6(&self.device_name);
|
||||
if !output6.is_null() {
|
||||
self.factory = factory;
|
||||
self.output6 = output6;
|
||||
} else {
|
||||
// Keep reading the old output, but enumerate again next time.
|
||||
self.factory = ComPtr(ptr::null_mut());
|
||||
}
|
||||
}
|
||||
let is_hdr = output_is_hdr(self.output6.0).unwrap_or(self.is_hdr);
|
||||
let level = if is_hdr {
|
||||
query_sdr_white_level(&self.device_name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// A transiently unreadable level keeps the last known one.
|
||||
if is_hdr == self.is_hdr && (level.is_none() || level == self.sdr_white_level) {
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"output changed: hdr {} -> {is_hdr}, sdr white level {:?} -> {level:?}",
|
||||
self.is_hdr,
|
||||
self.sdr_white_level
|
||||
);
|
||||
if is_hdr && level.is_none() {
|
||||
log::warn!(
|
||||
"HDR output but the SDR white level cannot be read, assuming 80 nits until it can"
|
||||
);
|
||||
}
|
||||
self.is_hdr = is_hdr;
|
||||
self.sdr_white_level = level;
|
||||
let data = params_data(level);
|
||||
(*self.context.0).UpdateSubresource(
|
||||
self.params.0 as *mut _,
|
||||
0,
|
||||
ptr::null(),
|
||||
data.as_ptr() as _,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// `None` is either a non-HDR (WCG) output, where 1.0 already is the display's
|
||||
// reference white, or an HDR output whose level is unknown; both use 1.0.
|
||||
fn params_data(sdr_white_level: Option<u32>) -> [f32; 4] {
|
||||
[
|
||||
1000.0 / sdr_white_level.unwrap_or(1000) as f32,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
]
|
||||
}
|
||||
|
||||
// FP16 desktop composition means either HDR (scene-referred, 1.0 == 80 nits)
|
||||
// or, since Windows 11 22H2, Advanced Color SDR (display-referred), and only
|
||||
// IDXGIOutput6 (Windows 10 1703) tells them apart. `None` when it cannot be
|
||||
// read right now.
|
||||
unsafe fn output_is_hdr(output6: *mut IDXGIOutput6) -> Option<bool> {
|
||||
if output6.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut desc: DXGI_OUTPUT_DESC1 = mem::zeroed();
|
||||
if (*output6).GetDesc1(&mut desc) != S_OK {
|
||||
return None;
|
||||
}
|
||||
Some(desc.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)
|
||||
}
|
||||
|
||||
unsafe fn query_output6(object: *mut IUnknown) -> ComPtr<IDXGIOutput6> {
|
||||
let mut output6: *mut IDXGIOutput6 = ptr::null_mut();
|
||||
if !object.is_null() {
|
||||
(*object).QueryInterface(
|
||||
&IID_IDXGIOutput6,
|
||||
&mut output6 as *mut *mut _ as *mut *mut _,
|
||||
);
|
||||
}
|
||||
ComPtr(output6)
|
||||
}
|
||||
|
||||
// A fresh factory sees the current display configuration; the output is found
|
||||
// by its GDI name because the outputs of a stale factory keep stale descriptions.
|
||||
// Returns both or neither: a non-null factory guarantees the output came from
|
||||
// its topology, so a caller that sees a null factory knows to enumerate again.
|
||||
unsafe fn enumerate_output6(
|
||||
device_name: &[WCHAR; 32],
|
||||
) -> (ComPtr<IDXGIFactory1>, ComPtr<IDXGIOutput6>) {
|
||||
let mut factory: *mut c_void = ptr::null_mut();
|
||||
if CreateDXGIFactory1(&IID_IDXGIFactory1, &mut factory) != S_OK {
|
||||
return (ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()));
|
||||
}
|
||||
let factory = ComPtr(factory as *mut IDXGIFactory1);
|
||||
let mut adapter_index = 0;
|
||||
loop {
|
||||
let mut adapter = ptr::null_mut();
|
||||
if (*factory.0).EnumAdapters1(adapter_index, &mut adapter) != S_OK {
|
||||
break;
|
||||
}
|
||||
let adapter = ComPtr(adapter);
|
||||
adapter_index += 1;
|
||||
let mut output_index = 0;
|
||||
loop {
|
||||
let mut output = ptr::null_mut();
|
||||
if (*adapter.0).EnumOutputs(output_index, &mut output) != S_OK {
|
||||
break;
|
||||
}
|
||||
let output = ComPtr(output);
|
||||
output_index += 1;
|
||||
let mut desc: DXGI_OUTPUT_DESC = mem::zeroed();
|
||||
if (*output.0).GetDesc(&mut desc) == S_OK && wide_eq(&desc.DeviceName, device_name) {
|
||||
let output6 = query_output6(output.0 as *mut IUnknown);
|
||||
if output6.is_null() {
|
||||
return (ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()));
|
||||
}
|
||||
return (factory, output6);
|
||||
}
|
||||
}
|
||||
}
|
||||
(ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()))
|
||||
}
|
||||
|
||||
fn other(msg: impl Into<String>) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::Other, msg.into())
|
||||
}
|
||||
|
||||
fn check(hr: HRESULT, what: &str) -> io::Result<()> {
|
||||
if hr == S_OK {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(other(format!("{what} failed: {hr:#x}")))
|
||||
}
|
||||
}
|
||||
|
||||
// D3DCompile(pSrcData, SrcDataSize, pSourceName, pDefines, pInclude,
|
||||
// pEntrypoint, pTarget, Flags1, Flags2, ppCode, ppErrorMsgs)
|
||||
type D3DCompileFn = unsafe extern "system" fn(
|
||||
LPCVOID,
|
||||
SIZE_T,
|
||||
LPCSTR,
|
||||
*const D3D_SHADER_MACRO,
|
||||
*mut ID3DInclude,
|
||||
LPCSTR,
|
||||
LPCSTR,
|
||||
UINT,
|
||||
UINT,
|
||||
*mut *mut ID3DBlob,
|
||||
*mut *mut ID3DBlob,
|
||||
) -> HRESULT;
|
||||
|
||||
static D3D_COMPILE: OnceLock<Result<D3DCompileFn, String>> = OnceLock::new();
|
||||
|
||||
// Loaded once per process and kept: the compiler DLL is only needed on HDR
|
||||
// desktops, and an import-time link would make every install depend on it.
|
||||
fn load_d3d_compile() -> io::Result<D3DCompileFn> {
|
||||
D3D_COMPILE
|
||||
.get_or_init(|| unsafe { find_d3d_compile() })
|
||||
.clone()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Unsupported, e))
|
||||
}
|
||||
|
||||
unsafe fn find_d3d_compile() -> Result<D3DCompileFn, String> {
|
||||
let name: Vec<u16> = "d3dcompiler_47.dll\0".encode_utf16().collect();
|
||||
let module = LoadLibraryExW(name.as_ptr(), ptr::null_mut(), LOAD_LIBRARY_SEARCH_SYSTEM32);
|
||||
if module.is_null() {
|
||||
return Err("d3dcompiler_47.dll not available".into());
|
||||
}
|
||||
let f = GetProcAddress(module, b"D3DCompile\0".as_ptr() as _);
|
||||
if f.is_null() {
|
||||
return Err("D3DCompile not exported".into());
|
||||
}
|
||||
Ok(mem::transmute::<_, D3DCompileFn>(f))
|
||||
}
|
||||
|
||||
unsafe fn compile_shader(
|
||||
compile: D3DCompileFn,
|
||||
src: &str,
|
||||
target: &[u8],
|
||||
) -> io::Result<ComPtr<ID3DBlob>> {
|
||||
let mut code = ptr::null_mut();
|
||||
let mut errors = ptr::null_mut();
|
||||
let hr = compile(
|
||||
src.as_ptr() as _,
|
||||
src.len(),
|
||||
ptr::null(),
|
||||
ptr::null(),
|
||||
ptr::null_mut(),
|
||||
b"main\0".as_ptr() as _,
|
||||
target.as_ptr() as _,
|
||||
0,
|
||||
0,
|
||||
&mut code,
|
||||
&mut errors,
|
||||
);
|
||||
let errors = ComPtr(errors);
|
||||
if hr != S_OK || code.is_null() {
|
||||
let msg = if errors.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
(*errors.0).GetBufferPointer() as *const u8,
|
||||
(*errors.0).GetBufferSize(),
|
||||
);
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
};
|
||||
if !code.is_null() {
|
||||
(*(code as *mut IUnknown)).Release();
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!("D3DCompile failed: {hr:#x} {msg}"),
|
||||
));
|
||||
}
|
||||
Ok(ComPtr(code))
|
||||
}
|
||||
|
||||
const DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL: u32 = 11;
|
||||
const QDC_ONLY_ACTIVE_PATHS: u32 = 2;
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_snake_case)]
|
||||
struct DISPLAYCONFIG_SDR_WHITE_LEVEL {
|
||||
header: DISPLAYCONFIG_DEVICE_INFO_HEADER,
|
||||
SDRWhiteLevel: ULONG,
|
||||
}
|
||||
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
fn GetDisplayConfigBufferSizes(
|
||||
flags: u32,
|
||||
numPathArrayElements: *mut u32,
|
||||
numModeInfoArrayElements: *mut u32,
|
||||
) -> LONG;
|
||||
fn QueryDisplayConfig(
|
||||
flags: u32,
|
||||
numPathArrayElements: *mut u32,
|
||||
pathArray: *mut DISPLAYCONFIG_PATH_INFO,
|
||||
numModeInfoArrayElements: *mut u32,
|
||||
modeInfoArray: *mut DISPLAYCONFIG_MODE_INFO,
|
||||
currentTopologyId: *mut DISPLAYCONFIG_TOPOLOGY_ID,
|
||||
) -> LONG;
|
||||
fn DisplayConfigGetDeviceInfo(requestPacket: *mut DISPLAYCONFIG_DEVICE_INFO_HEADER) -> LONG;
|
||||
}
|
||||
|
||||
/// SDR white level of the output whose GDI name is `device_name`
|
||||
/// (e.g. `\\.\DISPLAY1`), in DISPLAYCONFIG units (1000 == 80 nits). `None`
|
||||
/// when the query fails (before Windows 10 1709) or reports 0.
|
||||
fn query_sdr_white_level(device_name: &[WCHAR; 32]) -> Option<u32> {
|
||||
unsafe {
|
||||
let mut n_paths = 0u32;
|
||||
let mut n_modes = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut n_paths, &mut n_modes) != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut paths: Vec<DISPLAYCONFIG_PATH_INFO> = vec![mem::zeroed(); n_paths as usize];
|
||||
let mut modes: Vec<DISPLAYCONFIG_MODE_INFO> = vec![mem::zeroed(); n_modes as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ONLY_ACTIVE_PATHS,
|
||||
&mut n_paths,
|
||||
paths.as_mut_ptr(),
|
||||
&mut n_modes,
|
||||
modes.as_mut_ptr(),
|
||||
ptr::null_mut(),
|
||||
) != 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for path in &paths[..n_paths as usize] {
|
||||
let mut source: DISPLAYCONFIG_SOURCE_DEVICE_NAME = mem::zeroed();
|
||||
source.header._type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
|
||||
source.header.size = mem::size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as _;
|
||||
source.header.adapterId = path.sourceInfo.adapterId;
|
||||
source.header.id = path.sourceInfo.id;
|
||||
if DisplayConfigGetDeviceInfo(&mut source.header) != 0
|
||||
|| !wide_eq(&source.viewGdiDeviceName, device_name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut white: DISPLAYCONFIG_SDR_WHITE_LEVEL = mem::zeroed();
|
||||
white.header._type = DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL;
|
||||
white.header.size = mem::size_of::<DISPLAYCONFIG_SDR_WHITE_LEVEL>() as _;
|
||||
white.header.adapterId = path.targetInfo.adapterId;
|
||||
white.header.id = path.targetInfo.id;
|
||||
if DisplayConfigGetDeviceInfo(&mut white.header) == 0 && white.SDRWhiteLevel != 0 {
|
||||
return Some(white.SDRWhiteLevel);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn wide_eq(a: &[WCHAR], b: &[WCHAR]) -> bool {
|
||||
let end = |s: &[WCHAR]| s.iter().position(|&c| c == 0).unwrap_or(s.len());
|
||||
a[..end(a)] == b[..end(b)]
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
use std::{io, mem, ptr, slice};
|
||||
pub mod gdi;
|
||||
pub use gdi::CapturerGDI;
|
||||
pub mod hdr;
|
||||
pub mod mag;
|
||||
|
||||
use winapi::{
|
||||
shared::{
|
||||
dxgi::*,
|
||||
dxgi1_2::*,
|
||||
dxgi1_6::*,
|
||||
dxgiformat::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT},
|
||||
dxgitype::*,
|
||||
minwindef::{DWORD, FALSE, TRUE, UINT},
|
||||
ntdef::LONG,
|
||||
windef::{HMONITOR, RECT},
|
||||
winerror::*,
|
||||
// dxgiformat::{DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_420_OPAQUE},
|
||||
},
|
||||
um::{
|
||||
d3d11::*, d3dcommon::D3D_DRIVER_TYPE_UNKNOWN, unknwnbase::IUnknown, wingdi::*,
|
||||
@@ -60,7 +58,6 @@ pub struct Capturer {
|
||||
output_texture: bool,
|
||||
adapter_desc1: DXGI_ADAPTER_DESC1,
|
||||
rotate: Rotate,
|
||||
hdr: Option<hdr::HdrToSdr>,
|
||||
}
|
||||
|
||||
impl Capturer {
|
||||
@@ -108,7 +105,7 @@ impl Capturer {
|
||||
}
|
||||
} else {
|
||||
res = wrap_hresult(unsafe {
|
||||
let hres = Self::duplicate_output(&display, device.0, &mut duplication);
|
||||
let hres = (*display.inner.0).DuplicateOutput(device.0 as *mut _, &mut duplication);
|
||||
if hres != S_OK {
|
||||
gdi_capturer = display.create_gdi();
|
||||
println!("Fallback to GDI");
|
||||
@@ -164,8 +161,7 @@ impl Capturer {
|
||||
device,
|
||||
context,
|
||||
duplication: ComPtr(duplication),
|
||||
fastlane: desc.DesktopImageInSystemMemory == TRUE
|
||||
&& desc.ModeDesc.Format != DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
fastlane: desc.DesktopImageInSystemMemory == TRUE,
|
||||
surface: ComPtr(ptr::null_mut()),
|
||||
texture: ComPtr(ptr::null_mut()),
|
||||
width: display.width() as usize,
|
||||
@@ -178,102 +174,9 @@ impl Capturer {
|
||||
output_texture: false,
|
||||
adapter_desc1,
|
||||
rotate,
|
||||
hdr: None,
|
||||
})
|
||||
}
|
||||
|
||||
// Asks for the float desktop that HDR mode composes so it can be tone-mapped;
|
||||
// the legacy call would hand back DXGI's clipped BGRA8 conversion instead.
|
||||
// Only where IDXGIOutput6 (Windows 10 1703) exists, since that is what later
|
||||
// tells an HDR desktop from a WCG one; Microsoft's duplication sample gates
|
||||
// the float request the same way.
|
||||
unsafe fn duplicate_output(
|
||||
display: &Display,
|
||||
device: *mut ID3D11Device,
|
||||
duplication: &mut *mut IDXGIOutputDuplication,
|
||||
) -> HRESULT {
|
||||
if !hdr::UNAVAILABLE.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
let mut output6: *mut IDXGIOutput6 = ptr::null_mut();
|
||||
(*display.inner.0).QueryInterface(
|
||||
&IID_IDXGIOutput6,
|
||||
&mut output6 as *mut *mut _ as *mut *mut _,
|
||||
);
|
||||
if !output6.is_null() {
|
||||
let output6 = ComPtr(output6);
|
||||
let formats = [DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_B8G8R8A8_UNORM];
|
||||
let hres = (*output6.0).DuplicateOutput1(
|
||||
device as *mut _,
|
||||
0,
|
||||
formats.len() as UINT,
|
||||
formats.as_ptr(),
|
||||
duplication,
|
||||
);
|
||||
if hres == S_OK {
|
||||
return hres;
|
||||
}
|
||||
hbb_common::log::warn!(
|
||||
"HDR DuplicateOutput1 failed: hr={:#x}, fallback=DuplicateOutput",
|
||||
hres as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
(*display.inner.0).DuplicateOutput(device as *mut _, duplication)
|
||||
}
|
||||
|
||||
unsafe fn tonemap(
|
||||
&mut self,
|
||||
source: *mut ID3D11Texture2D,
|
||||
desc: &D3D11_TEXTURE2D_DESC,
|
||||
) -> io::Result<*mut ID3D11Texture2D> {
|
||||
if self.hdr.is_none() {
|
||||
match hdr::HdrToSdr::new(
|
||||
self.device.0,
|
||||
self.context.0,
|
||||
self.display.inner.0,
|
||||
&self.display.desc.DeviceName,
|
||||
) {
|
||||
Ok(hdr) => self.hdr = Some(hdr),
|
||||
Err(err) => return self.abandon_tonemap(err),
|
||||
}
|
||||
}
|
||||
let converted = match self.hdr.as_mut() {
|
||||
Some(hdr) => hdr.convert(source, desc),
|
||||
None => Err(io::Error::new(io::ErrorKind::Other, "no tone-map")),
|
||||
};
|
||||
match converted {
|
||||
Ok(texture) => Ok(texture),
|
||||
Err(err) => self.abandon_tonemap(err),
|
||||
}
|
||||
}
|
||||
|
||||
// Drops the tone-map and re-duplicates the output the legacy way, so DXGI
|
||||
// hands over clipped BGRA8 (the pre-HDR behaviour). If re-duplication fails,
|
||||
// switch to GDI before returning. The caller sees WouldBlock and asks again.
|
||||
unsafe fn abandon_tonemap<T>(&mut self, err: io::Error) -> io::Result<T> {
|
||||
if hdr::is_permanent(&err) {
|
||||
hdr::UNAVAILABLE.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
hbb_common::log::error!("HDR tone-map failed, re-duplicating without it: {err}");
|
||||
self.hdr = None;
|
||||
(*self.duplication.0).ReleaseFrame();
|
||||
self.duplication = ComPtr(ptr::null_mut());
|
||||
let mut duplication = ptr::null_mut();
|
||||
let result = wrap_hresult(
|
||||
(*self.display.inner.0).DuplicateOutput(self.device.0 as *mut _, &mut duplication),
|
||||
);
|
||||
if let Err(err) = result {
|
||||
if self.set_gdi() {
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
self.duplication = ComPtr(duplication);
|
||||
let mut desc: DXGI_OUTDUPL_DESC = mem::zeroed();
|
||||
(*duplication).GetDesc(&mut desc);
|
||||
self.fastlane = desc.DesktopImageInSystemMemory == TRUE;
|
||||
Err(io::ErrorKind::WouldBlock.into())
|
||||
}
|
||||
|
||||
fn create_rotations(
|
||||
device: *mut ID3D11Device,
|
||||
context: *mut ID3D11DeviceContext,
|
||||
@@ -427,9 +330,6 @@ impl Capturer {
|
||||
}
|
||||
|
||||
unsafe fn load_frame(&mut self, timeout: UINT) -> io::Result<(*const u8, i32)> {
|
||||
if self.duplication.0.is_null() {
|
||||
return Err(io::ErrorKind::AddrNotAvailable.into());
|
||||
}
|
||||
let mut frame = ptr::null_mut();
|
||||
#[allow(invalid_value)]
|
||||
let mut info = mem::MaybeUninit::uninit().assume_init();
|
||||
@@ -465,12 +365,6 @@ impl Capturer {
|
||||
let mut texture_desc = mem::MaybeUninit::uninit().assume_init();
|
||||
(*texture.0).GetDesc(&mut texture_desc);
|
||||
|
||||
let mut source = texture.0;
|
||||
if texture_desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT {
|
||||
source = self.tonemap(texture.0, &texture_desc)?;
|
||||
(*source).GetDesc(&mut texture_desc);
|
||||
}
|
||||
|
||||
texture_desc.Usage = D3D11_USAGE_STAGING;
|
||||
texture_desc.BindFlags = 0;
|
||||
texture_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
|
||||
@@ -491,7 +385,7 @@ impl Capturer {
|
||||
&mut surface as *mut *mut _ as *mut *mut _,
|
||||
);
|
||||
|
||||
(*self.context.0).CopyResource(readable.0 as *mut _, source as *mut _);
|
||||
(*self.context.0).CopyResource(readable.0 as *mut _, texture.0 as *mut _);
|
||||
|
||||
Ok(surface)
|
||||
}
|
||||
@@ -598,14 +492,6 @@ impl Capturer {
|
||||
let texture = ComPtr(texture);
|
||||
self.texture = texture;
|
||||
|
||||
let mut frame_desc: D3D11_TEXTURE2D_DESC = mem::zeroed();
|
||||
(*self.texture.0).GetDesc(&mut frame_desc);
|
||||
if frame_desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT {
|
||||
let converted = self.tonemap(self.texture.0, &frame_desc)?;
|
||||
(*converted).AddRef();
|
||||
self.texture = ComPtr(converted);
|
||||
}
|
||||
|
||||
let mut final_texture = self.texture.0 as *mut c_void;
|
||||
let mut rotation = match self.display.rotation() {
|
||||
DXGI_MODE_ROTATION_ROTATE90 => 90,
|
||||
@@ -689,9 +575,6 @@ impl Capturer {
|
||||
}
|
||||
|
||||
fn unmap(&self) {
|
||||
if self.duplication.0.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
(*self.duplication.0).ReleaseFrame();
|
||||
if self.fastlane {
|
||||
|
||||
@@ -297,6 +297,30 @@ pub fn clear_wayland_displays_cache() {
|
||||
// capturer rebuild loop clears about once a second.
|
||||
}
|
||||
|
||||
// Bumped ONLY by the layout-drift edge in display_service (its single owner), never by cache
|
||||
// clears: session inits and hotplug workers clear the cache too, and a bump there tears down
|
||||
// every OTHER live capturer on a multi-display session. A capturer records this at build and
|
||||
// treats a later bump as "the layout changed under me, rebuild" — the only trigger a rotation
|
||||
// has, since it changes neither the CRTC mode nor the framebuffer size (rustdesk#15886).
|
||||
static SNAPSHOT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Whether no snapshot has been cached: the signature of an enumeration that failed at session
|
||||
/// build (an `Err` is deliberately not cached), as opposed to a session that started healthy.
|
||||
#[cfg(feature = "drm")]
|
||||
pub fn wayland_snapshot_missing() -> bool {
|
||||
DISPLAYS.lock().unwrap().is_none()
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "drm"))]
|
||||
pub fn bump_layout_generation() {
|
||||
SNAPSHOT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
pub fn wayland_snapshot_generation() -> u64 {
|
||||
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
// Return (min_x, max_x, min_y, max_y)
|
||||
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
let wayland_displays = get_displays();
|
||||
@@ -332,7 +356,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
|
||||
// Otherwise, we use the logical size for `uinput`.
|
||||
if displays.len() == 1 {
|
||||
let d = &displays[0];
|
||||
return Some((d.x, d.x + d.width, d.y, d.y + d.height));
|
||||
let (w, h) = oriented_physical(d);
|
||||
return Some((d.x, d.x + w, d.y, d.y + h));
|
||||
}
|
||||
|
||||
let mut min_x = i32::MAX;
|
||||
@@ -344,6 +369,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
|
||||
min_y = min_y.min(d.y);
|
||||
let size = if let Some(logical_size) = d.logical_size {
|
||||
logical_size
|
||||
} else if d.transform == 90 || d.transform == 270 {
|
||||
oriented_physical(d)
|
||||
} else {
|
||||
// When `logical_size` is None, we cannot obtain the correct desktop rectangle.
|
||||
// This may occur if the Wayland compositor does not provide logical size information,
|
||||
@@ -374,6 +401,24 @@ pub struct DisplayRect {
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
// Carried so the drift comparison sees 0<->180 and 90<->270 flips, whose rects are
|
||||
// otherwise identical; the remap itself matches by name and containment, never by this.
|
||||
pub transform: i32,
|
||||
}
|
||||
|
||||
/// Physical size in delivered orientation: a 90/270 output scans out WxH but is captured,
|
||||
/// advertised and pointed at as HxW.
|
||||
fn oriented_physical(d: &WaylandDisplayInfo) -> (i32, i32) {
|
||||
if d.transform == 90 || d.transform == 270 {
|
||||
(d.height, d.width)
|
||||
} else {
|
||||
(d.width, d.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// The logical rectangles of a display list, for a caller that already has the list.
|
||||
pub fn logical_rects_of_displays(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
logical_rects_of(displays)
|
||||
}
|
||||
|
||||
fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
@@ -386,9 +431,9 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let (w, h) = if single {
|
||||
(d.width, d.height)
|
||||
oriented_physical(d)
|
||||
} else {
|
||||
d.logical_size.unwrap_or((d.width, d.height))
|
||||
d.logical_size.unwrap_or_else(|| oriented_physical(d))
|
||||
};
|
||||
DisplayRect {
|
||||
name: d.name.clone(),
|
||||
@@ -396,6 +441,7 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
y: d.y,
|
||||
w,
|
||||
h,
|
||||
transform: d.transform,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -495,8 +541,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_clear_keeps_the_failure_stamp() {
|
||||
// The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears,
|
||||
// and dropping the stamp with it would defeat the backoff. Sole test touching these
|
||||
// statics; serialize before adding another.
|
||||
// and dropping the stamp with it would defeat the backoff. The generation test also
|
||||
// calls clear now; both only assert monotonic/unchanged state, so they can interleave.
|
||||
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
|
||||
clear_wayland_displays_cache();
|
||||
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
|
||||
@@ -519,6 +565,7 @@ mod tests {
|
||||
height,
|
||||
logical_size,
|
||||
refresh_rate: 60,
|
||||
transform: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,6 +600,42 @@ mod tests {
|
||||
assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_rotated_display_swaps_the_uinput_rect() {
|
||||
// Review finding 1 on rustdesk#15889: the single-display branch served the unrotated
|
||||
// mode, so the pointer could not reach ~44% of a portrait screen.
|
||||
let mut d = display(0, 0, 1920, 1080, None);
|
||||
d.transform = 90;
|
||||
assert_eq!(desktop_rect_of(&[d.clone()]), Some((0, 1080, 0, 1920)));
|
||||
let rects = logical_rects_of(&[d]);
|
||||
assert_eq!((rects[0].w, rects[0].h), (1080, 1920));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_flip_is_visible_to_the_drift_comparison() {
|
||||
// Review finding 5: 0<->180 and 90<->270 leave every rect identical; the transform
|
||||
// field is what lets `baseline != live` fire on them.
|
||||
let mut a = display(0, 0, 1920, 1080, Some((1920, 1080)));
|
||||
let mut b = a.clone();
|
||||
a.transform = 90;
|
||||
b.transform = 270;
|
||||
assert_ne!(logical_rects_of(&[a.clone(), a.clone()]), logical_rects_of(&[b.clone(), b]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_explicit_bump_moves_the_generation() {
|
||||
// A cache clear must NOT bump: session inits clear too, and a bump there rebuilds
|
||||
// every other live capturer (adversarial finding on the first version of this).
|
||||
let before = SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire);
|
||||
clear_wayland_displays_cache();
|
||||
assert_eq!(
|
||||
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire),
|
||||
before
|
||||
);
|
||||
bump_layout_generation();
|
||||
assert!(SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire) > before);
|
||||
}
|
||||
|
||||
fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect {
|
||||
DisplayRect {
|
||||
name: name.to_owned(),
|
||||
@@ -560,6 +643,7 @@ mod tests {
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
transform: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
417
res/admin-roles.py
Executable file
417
res/admin-roles.py
Executable file
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
ROLE_TYPES = {
|
||||
"global": 1,
|
||||
"individual": 2,
|
||||
"group": 3,
|
||||
}
|
||||
|
||||
PERMISSION_IDS = {
|
||||
"users.view": 0x0101,
|
||||
"users.create": 0x0103,
|
||||
"users.invite": 0x0104,
|
||||
"users.delete": 0x0105,
|
||||
"users.enable_disable": 0x0106,
|
||||
"users.edit_email": 0x0107,
|
||||
"users.edit_password": 0x0108,
|
||||
"users.edit_note": 0x0109,
|
||||
"users.manage_2fa": 0x010A,
|
||||
"users.force_logout": 0x010B,
|
||||
"users.change_group": 0x010C,
|
||||
"users.change_strategy": 0x010D,
|
||||
"users.change_control_role": 0x010E,
|
||||
"users.edit_display_name": 0x010F,
|
||||
"devices.view": 0x0201,
|
||||
"devices.enable_disable": 0x0203,
|
||||
"devices.delete": 0x0204,
|
||||
"devices.edit_info": 0x0205,
|
||||
"devices.assign_to_user": 0x0206,
|
||||
"devices.change_group": 0x0207,
|
||||
"devices.change_strategy": 0x0208,
|
||||
"user_groups.view": 0x0301,
|
||||
"user_groups.edit": 0x0302,
|
||||
"device_groups.view": 0x0401,
|
||||
"device_groups.edit": 0x0402,
|
||||
"device_groups.change_strategy": 0x0403,
|
||||
"audits.view": 0x0501,
|
||||
"audits.edit": 0x0502,
|
||||
"strategies.view": 0x0601,
|
||||
"strategies.edit": 0x0602,
|
||||
"custom_clients.view": 0x0701,
|
||||
"custom_clients.edit": 0x0702,
|
||||
"control_roles.view": 0x0801,
|
||||
"control_roles.edit": 0x0802,
|
||||
}
|
||||
|
||||
PERMISSION_NAMES = {permission_id: name for name, permission_id in PERMISSION_IDS.items()}
|
||||
|
||||
|
||||
def check_response(response):
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code}: {response.text}")
|
||||
exit(1)
|
||||
|
||||
if response.text and response.text.strip():
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
print(f"Error: {data['error']}")
|
||||
exit(1)
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def headers_with(token):
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def split_csv(value):
|
||||
if value is None:
|
||||
return None
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def parse_permissions(value):
|
||||
permissions = []
|
||||
for item in split_csv(value) or []:
|
||||
permission = PERMISSION_IDS.get(item.lower())
|
||||
if permission is None:
|
||||
try:
|
||||
permission = int(item, 0)
|
||||
except ValueError:
|
||||
print(f"Error: Invalid permission name or ID '{item}'")
|
||||
exit(1)
|
||||
if permission < 0 or permission > 65535:
|
||||
print(f"Error: Permission ID '{item}' is outside the 0-65535 range")
|
||||
exit(1)
|
||||
permissions.append(permission)
|
||||
return permissions
|
||||
|
||||
|
||||
def format_role_permissions(role):
|
||||
permissions = role.get("permissions")
|
||||
if isinstance(permissions, list):
|
||||
role["permissions"] = [
|
||||
PERMISSION_NAMES.get(permission, permission) for permission in permissions
|
||||
]
|
||||
return role
|
||||
|
||||
|
||||
def list_roles(url, token, name=None, role_type=None, page_size=50):
|
||||
params = {"pageSize": page_size}
|
||||
if name is not None:
|
||||
params["name"] = name
|
||||
if role_type is not None:
|
||||
params["type"] = ROLE_TYPES[role_type]
|
||||
|
||||
roles = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/admin-roles", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing admin roles")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
roles.extend(format_role_permissions(role) for role in rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return roles
|
||||
|
||||
|
||||
def get_role(url, token, name=None, guid=None):
|
||||
if guid:
|
||||
response = requests.get(
|
||||
f"{url}/api/admin-roles/{guid}", headers=headers_with(token)
|
||||
)
|
||||
role = check_response(response)
|
||||
if isinstance(role, dict):
|
||||
return format_role_permissions(role)
|
||||
return role
|
||||
|
||||
roles = list_roles(url, token, name=name)
|
||||
for role in roles:
|
||||
if role.get("name") == name:
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
def resolve_role(url, token, name=None, guid=None):
|
||||
role = get_role(url, token, name=name, guid=guid)
|
||||
if role:
|
||||
return role
|
||||
target = guid if guid else name
|
||||
print(f"Error: Admin role '{target}' not found")
|
||||
exit(1)
|
||||
|
||||
|
||||
def get_user_guid(url, token, name):
|
||||
response = requests.get(
|
||||
f"{url}/api/users",
|
||||
headers=headers_with(token),
|
||||
params={"name": name, "pageSize": 50, "current": 1},
|
||||
)
|
||||
data = check_response(response)
|
||||
users = data.get("data", []) if isinstance(data, dict) else []
|
||||
for user in users:
|
||||
if user.get("name") == name:
|
||||
return user.get("guid")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_users(url, token, users):
|
||||
guids = []
|
||||
for user in users:
|
||||
if len(user) == 36 and user.count("-") == 4:
|
||||
guids.append(user)
|
||||
continue
|
||||
guid = get_user_guid(url, token, user)
|
||||
if not guid:
|
||||
print(f"Error: User '{user}' not found")
|
||||
exit(1)
|
||||
guids.append(guid)
|
||||
return guids
|
||||
|
||||
|
||||
def create_role(
|
||||
url,
|
||||
token,
|
||||
name,
|
||||
role_type,
|
||||
permissions,
|
||||
note=None,
|
||||
user_groups=None,
|
||||
device_groups=None,
|
||||
unassigned=None,
|
||||
):
|
||||
payload = {
|
||||
"name": name,
|
||||
"type": ROLE_TYPES[role_type],
|
||||
"permissions": permissions,
|
||||
}
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
if user_groups:
|
||||
payload["user_groups"] = user_groups
|
||||
if device_groups:
|
||||
payload["device_groups"] = device_groups
|
||||
if unassigned is not None:
|
||||
payload["unassigned"] = unassigned
|
||||
response = requests.post(
|
||||
f"{url}/api/admin-roles", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def update_role(
|
||||
url,
|
||||
token,
|
||||
guid,
|
||||
new_name=None,
|
||||
note=None,
|
||||
permissions=None,
|
||||
user_groups=None,
|
||||
device_groups=None,
|
||||
unassigned=None,
|
||||
):
|
||||
payload = {}
|
||||
if new_name is not None:
|
||||
payload["name"] = new_name
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
if permissions is not None:
|
||||
payload["permissions"] = permissions
|
||||
if user_groups is not None:
|
||||
payload["user_groups"] = user_groups
|
||||
if device_groups is not None:
|
||||
payload["device_groups"] = device_groups
|
||||
if unassigned is not None:
|
||||
payload["unassigned"] = unassigned
|
||||
response = requests.put(
|
||||
f"{url}/api/admin-roles/{guid}", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def delete_roles(url, token, guids):
|
||||
response = requests.delete(
|
||||
f"{url}/api/admin-roles",
|
||||
headers=headers_with(token),
|
||||
json={"guids": guids},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def change_users(url, token, guid, users, remove=False):
|
||||
method = requests.delete if remove else requests.post
|
||||
response = method(
|
||||
f"{url}/api/admin-roles/{guid}/users",
|
||||
headers=headers_with(token),
|
||||
json={"users": users},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def view_users(url, token, role_guid, page_size=50):
|
||||
params = {"admin_role_guid": role_guid, "pageSize": page_size}
|
||||
users = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/users", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing users")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
users.extend(rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return users
|
||||
|
||||
|
||||
def require_role_target(parser, args):
|
||||
if not args.name and not args.guid:
|
||||
parser.error("one of --name or --guid is required")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Admin role manager")
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["view", "add", "update", "delete", "view-users", "add-users", "remove-users"],
|
||||
)
|
||||
parser.add_argument("--url", required=True, help="Server URL")
|
||||
parser.add_argument("--token", required=True, help="API token")
|
||||
parser.add_argument("--name", help="Admin role name")
|
||||
parser.add_argument("--guid", help="Admin role GUID")
|
||||
parser.add_argument("--new-name", help="New admin role name")
|
||||
parser.add_argument("--note", help="Role note; use an empty value to clear it")
|
||||
parser.add_argument("--type", choices=ROLE_TYPES, help="Role type")
|
||||
parser.add_argument(
|
||||
"--permissions",
|
||||
help="Comma-separated permission names or numeric IDs; use an empty value to clear",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-groups",
|
||||
help="Comma-separated user group names; use an empty value to clear",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device-groups",
|
||||
help="Comma-separated device group names; use an empty value to clear",
|
||||
)
|
||||
parser.add_argument("--users", help="Comma-separated user names or GUIDs")
|
||||
unassigned = parser.add_mutually_exclusive_group()
|
||||
unassigned.add_argument(
|
||||
"--unassigned", dest="unassigned", action="store_true", help="Include unassigned devices"
|
||||
)
|
||||
unassigned.add_argument(
|
||||
"--no-unassigned",
|
||||
dest="unassigned",
|
||||
action="store_false",
|
||||
help="Exclude unassigned devices",
|
||||
)
|
||||
parser.set_defaults(unassigned=None)
|
||||
args = parser.parse_args()
|
||||
args.url = args.url.rstrip("/")
|
||||
|
||||
if args.command == "view":
|
||||
if args.guid:
|
||||
result = resolve_role(args.url, args.token, guid=args.guid)
|
||||
else:
|
||||
result = list_roles(args.url, args.token, args.name, args.type)
|
||||
print(json.dumps(result, indent=2))
|
||||
return
|
||||
|
||||
if args.command == "add":
|
||||
if not args.name or not args.type or args.permissions is None:
|
||||
parser.error("--name, --type, and --permissions are required for add")
|
||||
if args.type != "group" and (
|
||||
args.user_groups is not None
|
||||
or args.device_groups is not None
|
||||
or args.unassigned is not None
|
||||
):
|
||||
parser.error("group scope options can only be used with --type group")
|
||||
create_role(
|
||||
args.url,
|
||||
args.token,
|
||||
args.name,
|
||||
args.type,
|
||||
parse_permissions(args.permissions),
|
||||
args.note,
|
||||
split_csv(args.user_groups),
|
||||
split_csv(args.device_groups),
|
||||
args.unassigned,
|
||||
)
|
||||
print(f"Success: Created admin role '{args.name}'")
|
||||
return
|
||||
|
||||
require_role_target(parser, args)
|
||||
role = resolve_role(args.url, args.token, args.name, args.guid)
|
||||
role_guid = role.get("guid")
|
||||
role_name = role.get("name")
|
||||
|
||||
if args.command == "update":
|
||||
updates = [
|
||||
args.new_name,
|
||||
args.note,
|
||||
args.permissions,
|
||||
args.user_groups,
|
||||
args.device_groups,
|
||||
args.unassigned,
|
||||
]
|
||||
if all(value is None for value in updates):
|
||||
parser.error("at least one update option is required")
|
||||
if role.get("type") != ROLE_TYPES["group"] and (
|
||||
args.user_groups is not None
|
||||
or args.device_groups is not None
|
||||
or args.unassigned is not None
|
||||
):
|
||||
parser.error("group scope options can only be used with a group role")
|
||||
update_role(
|
||||
args.url,
|
||||
args.token,
|
||||
role_guid,
|
||||
args.new_name,
|
||||
args.note,
|
||||
parse_permissions(args.permissions) if args.permissions is not None else None,
|
||||
split_csv(args.user_groups),
|
||||
split_csv(args.device_groups),
|
||||
args.unassigned,
|
||||
)
|
||||
print(f"Success: Updated admin role '{role_name}'")
|
||||
elif args.command == "delete":
|
||||
delete_roles(args.url, args.token, [role_guid])
|
||||
print(f"Success: Deleted admin role '{role_name}'")
|
||||
elif args.command == "view-users":
|
||||
print(json.dumps(view_users(args.url, args.token, role_guid), indent=2))
|
||||
elif args.command in ("add-users", "remove-users"):
|
||||
users = split_csv(args.users)
|
||||
if not users:
|
||||
parser.error("--users is required for add-users and remove-users")
|
||||
user_guids = resolve_users(args.url, args.token, users)
|
||||
remove = args.command == "remove-users"
|
||||
change_users(args.url, args.token, role_guid, user_guids, remove=remove)
|
||||
action = "Removed users from" if remove else "Added users to"
|
||||
print(f"Success: {action} admin role '{role_name}'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
292
res/control-roles.py
Executable file
292
res/control-roles.py
Executable file
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
STATUSES = {
|
||||
"disabled": 0,
|
||||
"enabled": 1,
|
||||
}
|
||||
|
||||
|
||||
def check_response(response):
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code}: {response.text}")
|
||||
exit(1)
|
||||
|
||||
if response.text and response.text.strip():
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
print(f"Error: {data['error']}")
|
||||
exit(1)
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def headers_with(token):
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def split_csv(value):
|
||||
if value is None:
|
||||
return None
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def list_roles(url, token, name=None, status=None, page_size=50):
|
||||
params = {"pageSize": page_size}
|
||||
if name is not None:
|
||||
params["name"] = name
|
||||
if status is not None:
|
||||
params["status"] = STATUSES[status]
|
||||
|
||||
roles = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/control-roles", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing control roles")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
for role in rows:
|
||||
role.pop("info", None)
|
||||
roles.extend(rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return roles
|
||||
|
||||
|
||||
def get_role(url, token, name=None, guid=None):
|
||||
if guid:
|
||||
response = requests.get(
|
||||
f"{url}/api/control-roles/{guid}", headers=headers_with(token)
|
||||
)
|
||||
role = check_response(response)
|
||||
if isinstance(role, dict):
|
||||
role.pop("info", None)
|
||||
return role
|
||||
|
||||
roles = list_roles(url, token, name=name)
|
||||
for role in roles:
|
||||
if role.get("name") == name:
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
def resolve_role(url, token, name=None, guid=None):
|
||||
role = get_role(url, token, name=name, guid=guid)
|
||||
if role:
|
||||
return role
|
||||
target = guid if guid else name
|
||||
print(f"Error: Control role '{target}' not found")
|
||||
exit(1)
|
||||
|
||||
|
||||
def get_user_guid(url, token, name):
|
||||
response = requests.get(
|
||||
f"{url}/api/users",
|
||||
headers=headers_with(token),
|
||||
params={"name": name, "pageSize": 50, "current": 1},
|
||||
)
|
||||
data = check_response(response)
|
||||
users = data.get("data", []) if isinstance(data, dict) else []
|
||||
for user in users:
|
||||
if user.get("name") == name:
|
||||
return user.get("guid")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_users(url, token, users):
|
||||
guids = []
|
||||
for user in users:
|
||||
if len(user) == 36 and user.count("-") == 4:
|
||||
guids.append(user)
|
||||
continue
|
||||
guid = get_user_guid(url, token, user)
|
||||
if not guid:
|
||||
print(f"Error: User '{user}' not found")
|
||||
exit(1)
|
||||
guids.append(guid)
|
||||
return guids
|
||||
|
||||
|
||||
def create_role(url, token, name, note=None):
|
||||
payload = {"name": name}
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
response = requests.post(
|
||||
f"{url}/api/control-roles", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def update_role(url, token, guid, new_name=None, note=None):
|
||||
payload = {}
|
||||
if new_name is not None:
|
||||
payload["name"] = new_name
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
response = requests.put(
|
||||
f"{url}/api/control-roles/{guid}", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def delete_roles(url, token, guids):
|
||||
response = requests.delete(
|
||||
f"{url}/api/control-roles",
|
||||
headers=headers_with(token),
|
||||
json={"guids": guids},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def set_status(url, token, guids, disable):
|
||||
response = requests.put(
|
||||
f"{url}/api/control-roles/enable",
|
||||
headers=headers_with(token),
|
||||
json={"guids": guids, "disable": disable},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def change_users(url, token, guid, users, remove=False):
|
||||
if remove:
|
||||
endpoint = f"{url}/api/control-roles/users"
|
||||
response = requests.delete(
|
||||
endpoint,
|
||||
headers=headers_with(token),
|
||||
json={"user_guids": users},
|
||||
)
|
||||
else:
|
||||
endpoint = f"{url}/api/control-roles/{guid}/users"
|
||||
response = requests.post(
|
||||
endpoint,
|
||||
headers=headers_with(token),
|
||||
json={"user_guids": users},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def view_users(url, token, role_guid, page_size=50):
|
||||
params = {"control_role_guid": role_guid, "pageSize": page_size}
|
||||
users = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/users", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing users")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
users.extend(rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return users
|
||||
|
||||
|
||||
def require_role_target(parser, args):
|
||||
if not args.name and not args.guid:
|
||||
parser.error("one of --name or --guid is required")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Control role manager (configure control permissions in the web console)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=[
|
||||
"view",
|
||||
"add",
|
||||
"update",
|
||||
"delete",
|
||||
"enable",
|
||||
"disable",
|
||||
"view-users",
|
||||
"assign-users",
|
||||
"remove-users",
|
||||
],
|
||||
)
|
||||
parser.add_argument("--url", required=True, help="Server URL")
|
||||
parser.add_argument("--token", required=True, help="API token")
|
||||
parser.add_argument("--name", help="Control role name")
|
||||
parser.add_argument("--guid", help="Control role GUID")
|
||||
parser.add_argument("--new-name", help="New control role name")
|
||||
parser.add_argument("--note", help="Role note; use an empty value to clear it")
|
||||
parser.add_argument("--status", choices=STATUSES, help="Status filter for view")
|
||||
parser.add_argument("--users", help="Comma-separated user names or GUIDs")
|
||||
args = parser.parse_args()
|
||||
args.url = args.url.rstrip("/")
|
||||
|
||||
if args.command == "view":
|
||||
if args.guid:
|
||||
result = resolve_role(args.url, args.token, guid=args.guid)
|
||||
else:
|
||||
result = list_roles(args.url, args.token, args.name, args.status)
|
||||
print(json.dumps(result, indent=2))
|
||||
return
|
||||
|
||||
if args.command == "add":
|
||||
if not args.name:
|
||||
parser.error("--name is required for add")
|
||||
create_role(args.url, args.token, args.name, args.note)
|
||||
print(f"Success: Created control role '{args.name}'")
|
||||
return
|
||||
|
||||
if args.command == "remove-users":
|
||||
users = split_csv(args.users)
|
||||
if not users:
|
||||
parser.error("--users is required for remove-users")
|
||||
user_guids = resolve_users(args.url, args.token, users)
|
||||
change_users(args.url, args.token, None, user_guids, remove=True)
|
||||
print("Success: Removed users from their control roles")
|
||||
return
|
||||
|
||||
require_role_target(parser, args)
|
||||
role = resolve_role(args.url, args.token, args.name, args.guid)
|
||||
role_guid = role.get("guid")
|
||||
role_name = role.get("name")
|
||||
|
||||
if args.command == "update":
|
||||
if args.new_name is None and args.note is None:
|
||||
parser.error("--new-name or --note is required for update")
|
||||
update_role(args.url, args.token, role_guid, args.new_name, args.note)
|
||||
print(f"Success: Updated control role '{role_name}'")
|
||||
elif args.command == "delete":
|
||||
delete_roles(args.url, args.token, [role_guid])
|
||||
print(f"Success: Deleted control role '{role_name}'")
|
||||
elif args.command in ("enable", "disable"):
|
||||
disable = args.command == "disable"
|
||||
set_status(args.url, args.token, [role_guid], disable)
|
||||
print(f"Success: {args.command.title()}d control role '{role_name}'")
|
||||
elif args.command == "view-users":
|
||||
print(json.dumps(view_users(args.url, args.token, role_guid), indent=2))
|
||||
elif args.command == "assign-users":
|
||||
users = split_csv(args.users)
|
||||
if not users:
|
||||
parser.error("--users is required for assign-users")
|
||||
user_guids = resolve_users(args.url, args.token, users)
|
||||
change_users(args.url, args.token, role_guid, user_guids)
|
||||
print(f"Success: Assigned users to control role '{role_name}'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -18,6 +18,9 @@ void UninstallDriver(LPCWSTR hardwareId, BOOL &rebootRequired);
|
||||
|
||||
namespace RemotePrinter
|
||||
{
|
||||
VOID installUpdatePrinter(const std::wstring& installFolder);
|
||||
VOID uninstallPrinter();
|
||||
// `appName` names the printer and its port. It is passed in rather than compiled
|
||||
// in so that a single dll serves every custom client; an empty value keeps the
|
||||
// stock "RustDesk Printer" name.
|
||||
VOID installUpdatePrinter(const std::wstring& installFolder, const std::wstring& appName);
|
||||
VOID uninstallPrinter(const std::wstring& appName);
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ bool TerminateProcessesByNameW(LPCWSTR processName, LPCWSTR excludeParam)
|
||||
{
|
||||
do
|
||||
{
|
||||
if (lstrcmpW(processName, processEntry.szExeFile) == 0)
|
||||
if (lstrcmpiW(processName, processEntry.szExeFile) == 0)
|
||||
{
|
||||
HANDLE process = OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processEntry.th32ProcessID);
|
||||
if (process != NULL)
|
||||
@@ -1021,9 +1021,9 @@ UINT __stdcall InstallPrinter(
|
||||
DWORD er = ERROR_SUCCESS;
|
||||
|
||||
int nResult = 0;
|
||||
LPWSTR installFolder = NULL;
|
||||
LPWSTR pwz = NULL;
|
||||
LPWSTR pwzData = NULL;
|
||||
std::wstring appNameValue;
|
||||
std::wstring installFolderValue;
|
||||
|
||||
hr = WcaInitialize(hInstall, "InstallPrinter");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
@@ -1031,12 +1031,27 @@ UINT __stdcall InstallPrinter(
|
||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||
ExitOnFailure(hr, "failed to get CustomActionData");
|
||||
|
||||
pwz = pwzData;
|
||||
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
||||
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
|
||||
// "<app name>|<install folder>". Split here rather than through
|
||||
// WcaReadStringFromCaData, whose delimiter is a literal wide char 128 that a
|
||||
// Formatted property value cannot carry.
|
||||
{
|
||||
std::wstring data(pwzData);
|
||||
size_t separator = data.find(L'|');
|
||||
if (separator == std::wstring::npos)
|
||||
{
|
||||
// A package built before the name was passed in; keep the stock name.
|
||||
appNameValue.clear();
|
||||
installFolderValue = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
appNameValue = data.substr(0, separator);
|
||||
installFolderValue = data.substr(separator + 1);
|
||||
}
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolder);
|
||||
RemotePrinter::installUpdatePrinter(installFolder);
|
||||
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolderValue.c_str());
|
||||
RemotePrinter::installUpdatePrinter(installFolderValue, appNameValue);
|
||||
WcaLog(LOGMSG_STANDARD, "Install RD printer done");
|
||||
|
||||
LExit:
|
||||
@@ -1054,14 +1069,30 @@ UINT __stdcall UninstallPrinter(
|
||||
HRESULT hr = S_OK;
|
||||
DWORD er = ERROR_SUCCESS;
|
||||
|
||||
LPWSTR pwzData = NULL;
|
||||
std::wstring appNameValue;
|
||||
|
||||
hr = WcaInitialize(hInstall, "UninstallPrinter");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
|
||||
// Must match the name install used, otherwise the printer is left behind. Absent
|
||||
// on packages built before this was passed in, where it was the stock name.
|
||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||
ExitOnFailure(hr, "failed to get CustomActionData");
|
||||
if (pwzData)
|
||||
{
|
||||
appNameValue = pwzData;
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Try to uninstall RD printer");
|
||||
RemotePrinter::uninstallPrinter();
|
||||
RemotePrinter::uninstallPrinter(appNameValue);
|
||||
WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done");
|
||||
|
||||
LExit:
|
||||
if (pwzData) {
|
||||
ReleaseStr(pwzData);
|
||||
}
|
||||
|
||||
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
|
||||
return WcaFinalize(er);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,19 @@ namespace RemotePrinter
|
||||
{
|
||||
#define HRESULT_ERR_ELEMENT_NOT_FOUND 0x80070490
|
||||
|
||||
// The driver files and the driver name ship with the app under their stock names
|
||||
// and stay fixed for every custom client. Only the printer and its port carry the
|
||||
// app name, and that arrives at runtime so one dll serves every custom client.
|
||||
LPCWCH RD_DRIVER_INF_PATH = L"drivers\\RustDeskPrinterDriver\\RustDeskPrinterDriver.inf";
|
||||
LPCWCH RD_PRINTER_PORT = L"RustDesk Printer";
|
||||
LPCWCH RD_PRINTER_NAME = L"RustDesk Printer";
|
||||
LPCWCH RD_PRINTER_DRIVER_NAME = L"RustDesk v4 Printer Driver";
|
||||
LPCWCH RD_DEFAULT_APP_NAME = L"RustDesk";
|
||||
LPCWCH XCV_MONITOR_LOCAL_PORT = L",XcvMonitor Local Port";
|
||||
|
||||
static std::wstring printerNameOf(const std::wstring &appName)
|
||||
{
|
||||
return (appName.empty() ? std::wstring(RD_DEFAULT_APP_NAME) : appName) + L" Printer";
|
||||
}
|
||||
|
||||
using FuncEnum = std::function<BOOL(DWORD level, LPBYTE pDriverInfo, DWORD cbBuf, LPDWORD pcbNeeded, LPDWORD pcReturned)>;
|
||||
template <typename T, typename R>
|
||||
using FuncOnData = std::function<std::shared_ptr<R>(const T &)>;
|
||||
@@ -458,8 +465,12 @@ namespace RemotePrinter
|
||||
// We should not check the driver version because the driver is deployed with the application.
|
||||
// It's better to uninstall the existing driver and install the driver from the application.
|
||||
// 3. Add the printer.
|
||||
VOID installUpdatePrinter(const std::wstring &installFolder)
|
||||
VOID installUpdatePrinter(const std::wstring &installFolder, const std::wstring &appName)
|
||||
{
|
||||
const std::wstring printerName = printerNameOf(appName);
|
||||
const LPCWCH RD_PRINTER_NAME = printerName.c_str();
|
||||
const LPCWCH RD_PRINTER_PORT = printerName.c_str();
|
||||
|
||||
const std::wstring infFile = installFolder + L"\\" + RemotePrinter::RD_DRIVER_INF_PATH;
|
||||
if (!FileExists(infFile))
|
||||
{
|
||||
@@ -505,13 +516,15 @@ namespace RemotePrinter
|
||||
}
|
||||
}
|
||||
|
||||
VOID uninstallPrinter()
|
||||
VOID uninstallPrinter(const std::wstring &appName)
|
||||
{
|
||||
deletePrinter(RD_PRINTER_NAME);
|
||||
const std::wstring printerName = printerNameOf(appName);
|
||||
|
||||
deletePrinter(printerName.c_str());
|
||||
WcaLog(LOGMSG_STANDARD, "Deleted the printer\n");
|
||||
uninstallDriver(RD_PRINTER_DRIVER_NAME);
|
||||
WcaLog(LOGMSG_STANDARD, "Uninstalled the printer driver\n");
|
||||
checkDeleteLocalPort(RD_PRINTER_PORT);
|
||||
checkDeleteLocalPort(printerName.c_str());
|
||||
WcaLog(LOGMSG_STANDARD, "Deleted the local port\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,14 @@
|
||||
<CustomAction Id="SetPropertyServiceStop.SetParam.PropertyName" Return="check" Property="PropertyName" Value="STOP_SERVICE" />
|
||||
<CustomAction Id="TryDeleteStartupShortcut.SetParam" Return="check" Property="ShortcutName" Value="$(var.Product) Tray" />
|
||||
<CustomAction Id="RemoveAmyuniIdd.SetParam" Return="check" Property="RemoveAmyuniIdd" Value="[INSTALLFOLDER_INNER]" />
|
||||
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[INSTALLFOLDER_INNER]" />
|
||||
<!-- The app name comes first and is separated by '|', which cannot occur in a
|
||||
Windows path nor in a validated app name. wcautil's own delimiter is a
|
||||
literal wide char 128 that a Formatted value cannot carry, and [~] is
|
||||
MSI's NUL escape rather than that delimiter, so the action parses this
|
||||
itself. Passing the name keeps the dll free of it, so one build serves
|
||||
every custom client. -->
|
||||
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[ProductName]|[INSTALLFOLDER_INNER]" />
|
||||
<CustomAction Id="UninstallPrinter.SetParam" Return="check" Property="UninstallPrinter" Value="[ProductName]" />
|
||||
<InstallExecuteSequence>
|
||||
|
||||
<Custom Action="SetPropertyIsServiceRunning" After="InstallInitialize" Condition="Installed" />
|
||||
@@ -86,6 +93,7 @@
|
||||
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
||||
|
||||
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT >= 603" />
|
||||
<Custom Action="UninstallPrinter.SetParam" Before="UninstallPrinter" Condition="VersionNT >= 603" />
|
||||
|
||||
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
<PropertyRef Id="AddRemovePropertiesFile" />
|
||||
|
||||
<Media Id="1" Cabinet="cab1.cab" EmbedCab="yes" CompressionLevel="high" />
|
||||
<!--$Media2Start$-->
|
||||
<!-- preprocess.py in template mode adds a second cabinet here, holding only
|
||||
the files that differ per customer, so a custom client can be produced by
|
||||
rebuilding that small cabinet instead of the whole package. The shipped
|
||||
msi is built without template mode and keeps a single cabinet. -->
|
||||
<!--$Media2End$-->
|
||||
<Icon Id="AppIcon" SourceFile="Resources\icon.ico" />
|
||||
<CustomAction Id="BlockSelfInstalledApp" Error="!(loc.AnotherAppDialogDescription)" />
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import subprocess
|
||||
import re
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from itertools import chain
|
||||
import shutil
|
||||
from xml.sax.saxutils import quoteattr
|
||||
|
||||
@@ -67,6 +66,14 @@ def make_parser():
|
||||
parser.add_argument(
|
||||
"-c", "--custom", action="store_true", help="Is custom client", default=False
|
||||
)
|
||||
parser.add_argument(
|
||||
"--template",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Build a template to be patched per customer rather than a finished "
|
||||
"package: puts the files a custom client replaces in their own cabinet, so "
|
||||
"rebranding rebuilds a few hundred KB instead of the whole payload.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--conn-type",
|
||||
type=str,
|
||||
@@ -92,6 +99,43 @@ def make_parser():
|
||||
return parser
|
||||
|
||||
|
||||
# Files a custom client replaces. Kept in their own cabinet by --template so that
|
||||
# rebranding rebuilds a few hundred KB instead of recompressing the whole payload.
|
||||
# The app executable is handled separately: it has its own component in RustDesk.wxs.
|
||||
#
|
||||
# A template has to ship a placeholder for each of these so there is a File row to
|
||||
# patch, but the branding assets are optional for a customer and a stock build has
|
||||
# none of them at all. So each optional one installs only when its property is set,
|
||||
# which the patcher does for the files a customer actually supplied. Otherwise a
|
||||
# customer without a logo would install the placeholder, where today they get no
|
||||
# logo at all -- the client treats a missing asset as "no logo".
|
||||
PER_CUSTOMER_DISK_ID = 2
|
||||
PER_CUSTOMER_FILES = {
|
||||
# relative path -> property gating installation, or None if always installed
|
||||
"custom.txt": None,
|
||||
"data/flutter_assets/assets/icon.ico": "CC_HAS_ICON_ICO",
|
||||
"data/flutter_assets/assets/icon.png": "CC_HAS_ICON_PNG",
|
||||
"data/flutter_assets/assets/logo.png": "CC_HAS_LOGO",
|
||||
"data/flutter_assets/assets/logo_light.png": "CC_HAS_LOGO_LIGHT",
|
||||
"data/flutter_assets/assets/logo_dark.png": "CC_HAS_LOGO_DARK",
|
||||
}
|
||||
|
||||
|
||||
def normalize_relative(relative_path):
|
||||
path = relative_path.replace("\\", "/")
|
||||
while path.startswith("./"):
|
||||
path = path[2:]
|
||||
return path.lower()
|
||||
|
||||
|
||||
def is_per_customer(relative_path):
|
||||
return normalize_relative(relative_path) in PER_CUSTOMER_FILES
|
||||
|
||||
|
||||
def per_customer_condition(relative_path):
|
||||
return PER_CUSTOMER_FILES.get(normalize_relative(relative_path))
|
||||
|
||||
|
||||
def read_lines_and_start_index(file_path, tag_start, tag_end):
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
@@ -112,7 +156,7 @@ def read_lines_and_start_index(file_path, tag_start, tag_end):
|
||||
return lines, index_start
|
||||
|
||||
|
||||
def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
||||
def insert_components_between_tags(lines, index_start, app_name, dist_dir, template=False):
|
||||
indent = g_indent_unit * 3
|
||||
path = Path(dist_dir)
|
||||
idx = 1
|
||||
@@ -126,12 +170,23 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
||||
if subdir != ".":
|
||||
dir_attr = f'Subdirectory="{subdir}"'
|
||||
|
||||
relative = file_path.relative_to(path).as_posix()
|
||||
disk_attr = ""
|
||||
condition_attr = ""
|
||||
if template and is_per_customer(relative):
|
||||
disk_attr = f' DiskId="{PER_CUSTOMER_DISK_ID}"'
|
||||
# Branding assets are optional, and the template only carries a
|
||||
# placeholder, so install one only when the customer supplied it.
|
||||
condition = per_customer_condition(relative)
|
||||
if condition:
|
||||
condition_attr = f' Condition="{condition} = 1"'
|
||||
|
||||
# Don't generate Component Id and File Id like 'Component_{idx}' and 'File_{idx}'
|
||||
# because it will cause error
|
||||
# "Error WIX0130 The primary key 'xxxx' is duplicated in table 'Directory'"
|
||||
to_insert_lines = f"""
|
||||
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}>
|
||||
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes" />
|
||||
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}{condition_attr}>
|
||||
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes"{disk_attr} />
|
||||
{indent}</Component>
|
||||
"""
|
||||
lines.insert(index_start + 1, to_insert_lines[1:])
|
||||
@@ -140,17 +195,52 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
||||
return True
|
||||
|
||||
|
||||
def gen_auto_component(app_name, dist_dir):
|
||||
def gen_auto_component(app_name, dist_dir, template=False):
|
||||
return gen_content_between_tags(
|
||||
"Package/Components/RustDesk.wxs",
|
||||
"<!--$AutoComonentStart$-->",
|
||||
"<!--$AutoComponentEnd$-->",
|
||||
lambda lines, index_start: insert_components_between_tags(
|
||||
lines, index_start, app_name, dist_dir
|
||||
lines, index_start, app_name, dist_dir, template
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def gen_media2():
|
||||
"""Second cabinet holding only what a custom client replaces."""
|
||||
|
||||
def func(lines, index_start):
|
||||
indent = g_indent_unit * 2
|
||||
lines.insert(
|
||||
index_start + 1,
|
||||
f'{indent}<Media Id="{PER_CUSTOMER_DISK_ID}" Cabinet="cab2.cab"'
|
||||
' EmbedCab="yes" CompressionLevel="high" />\n',
|
||||
)
|
||||
return lines
|
||||
|
||||
return gen_content_between_tags(
|
||||
"Package/Package.wxs", "<!--$Media2Start$-->", "<!--$Media2End$-->", func
|
||||
)
|
||||
|
||||
|
||||
def put_app_exe_on_media2():
|
||||
"""The app executable has its own component, so it is moved by name."""
|
||||
target = Path(sys.argv[0]).parent.joinpath("Package/Components/RustDesk.wxs")
|
||||
with open(target, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
old = '<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes">'
|
||||
new = (
|
||||
'<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes"'
|
||||
f' DiskId="{PER_CUSTOMER_DISK_ID}">'
|
||||
)
|
||||
if content.count(old) != 1:
|
||||
print(f"Error: expected exactly one App.exe File element, found {content.count(old)}")
|
||||
return False
|
||||
with open(target, "w", encoding="utf-8") as f:
|
||||
f.write(content.replace(old, new))
|
||||
return True
|
||||
|
||||
|
||||
def gen_pre_vars(args, dist_dir):
|
||||
def func(lines, index_start):
|
||||
upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe")
|
||||
@@ -190,18 +280,6 @@ def replace_app_name_in_langs(app_name):
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
def replace_app_name_in_custom_actions(app_name):
|
||||
custion_actions_dir = Path(sys.argv[0]).parent.joinpath("CustomActions")
|
||||
for file_path in chain(custion_actions_dir.glob("*.cpp"), custion_actions_dir.glob("*.h")):
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines):
|
||||
line = re.sub(r"\bRustDesk\b", app_name, line)
|
||||
line = line.replace(f"{app_name} v4 Printer Driver", "RustDesk v4 Printer Driver")
|
||||
lines[i] = line
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
def gen_upgrade_info():
|
||||
def func(lines, index_start):
|
||||
indent = g_indent_unit * 3
|
||||
@@ -478,11 +556,16 @@ if __name__ == "__main__":
|
||||
if not gen_conn_type(args):
|
||||
sys.exit(-1)
|
||||
|
||||
if not gen_auto_component(app_name, dist_dir):
|
||||
if args.template:
|
||||
if not gen_media2():
|
||||
sys.exit(-1)
|
||||
if not put_app_exe_on_media2():
|
||||
sys.exit(-1)
|
||||
|
||||
if not gen_auto_component(app_name, dist_dir, args.template):
|
||||
sys.exit(-1)
|
||||
|
||||
if not gen_custom_dialog_bitmaps():
|
||||
sys.exit(-1)
|
||||
|
||||
replace_app_name_in_langs(args.app_name)
|
||||
replace_app_name_in_custom_actions(args.app_name)
|
||||
|
||||
1320
src/client.rs
1320
src/client.rs
File diff suppressed because it is too large
Load Diff
@@ -185,6 +185,14 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
.unwrap()
|
||||
.set_connected();
|
||||
let is_secured = peer.is_secured();
|
||||
// Only WebRTC needs refining: its label names the transport that won the race,
|
||||
// not the family ICE ended up nominating, and it is the one path where the two
|
||||
// can disagree with the address the rendezvous observed.
|
||||
let stream_type = if peer.webrtc_remote_ipv6().await.unwrap_or(false) {
|
||||
"WebRTC/IPv6"
|
||||
} else {
|
||||
stream_type
|
||||
};
|
||||
self.handler
|
||||
.set_connection_type(is_secured, direct, stream_type); // flutter -> connection_ready
|
||||
if !is_secured
|
||||
|
||||
259
src/common.rs
259
src/common.rs
@@ -1,7 +1,7 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
net::{SocketAddr, ToSocketAddrs},
|
||||
net::SocketAddr,
|
||||
sync::{Arc, Mutex, RwLock},
|
||||
task::Poll,
|
||||
};
|
||||
@@ -1153,6 +1153,13 @@ pub fn is_public(url: &str) -> bool {
|
||||
host == "rustdesk.com" || host.ends_with(".rustdesk.com")
|
||||
}
|
||||
|
||||
pub fn get_tcp_punch_enabled() -> bool {
|
||||
config::option2bool(
|
||||
keys::OPTION_ENABLE_TCP_PUNCH,
|
||||
&get_local_option(keys::OPTION_ENABLE_TCP_PUNCH),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_udp_punch_enabled() -> bool {
|
||||
config::option2bool(
|
||||
keys::OPTION_ENABLE_UDP_PUNCH,
|
||||
@@ -1167,9 +1174,19 @@ pub fn get_ipv6_punch_enabled() -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_webrtc_enabled() -> bool {
|
||||
config::option2bool(
|
||||
keys::OPTION_ENABLE_WEBRTC,
|
||||
&get_local_option(keys::OPTION_ENABLE_WEBRTC),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_local_option(key: &str) -> String {
|
||||
let v = LocalConfig::get_option(key);
|
||||
if key == keys::OPTION_ENABLE_UDP_PUNCH || key == keys::OPTION_ENABLE_IPV6_PUNCH {
|
||||
if key == keys::OPTION_ENABLE_UDP_PUNCH
|
||||
|| key == keys::OPTION_ENABLE_IPV6_PUNCH
|
||||
|| key == keys::OPTION_ENABLE_WEBRTC
|
||||
{
|
||||
if v.is_empty() {
|
||||
if !is_public(&Config::get_rendezvous_server()) {
|
||||
return "N".to_owned();
|
||||
@@ -2126,11 +2143,21 @@ pub fn get_rs_pk(str_base64: &str) -> Option<sign::PublicKey> {
|
||||
}
|
||||
|
||||
pub fn decode_id_pk(signed: &[u8], key: &sign::PublicKey) -> ResultType<(String, [u8; 32])> {
|
||||
let (id, pk, _) = decode_id_pk_dtls(signed, key)?;
|
||||
Ok((id, pk))
|
||||
}
|
||||
|
||||
/// Like [`decode_id_pk`] but also returns the signed DTLS certificate fingerprint (empty string
|
||||
/// for non-WebRTC peers), used to bind a WebRTC DTLS channel to the verified peer identity.
|
||||
pub fn decode_id_pk_dtls(
|
||||
signed: &[u8],
|
||||
key: &sign::PublicKey,
|
||||
) -> ResultType<(String, [u8; 32], String)> {
|
||||
let res = IdPk::parse_from_bytes(
|
||||
&sign::verify(signed, key).map_err(|_| anyhow!("Signature mismatch"))?,
|
||||
)?;
|
||||
if let Some(pk) = get_pk(&res.pk) {
|
||||
Ok((res.id, pk))
|
||||
Ok((res.id, pk, res.dtls_fingerprint))
|
||||
} else {
|
||||
bail!("Wrong their public length");
|
||||
}
|
||||
@@ -2432,16 +2459,26 @@ pub fn is_udp_disabled() -> bool {
|
||||
Config::get_option(keys::OPTION_DISABLE_UDP) == "Y"
|
||||
}
|
||||
|
||||
/// Run KCP with its congestion window (nc=0) instead of the turbo profile it has always shipped.
|
||||
///
|
||||
/// Opt-in: which profile wins depends on why packets are lost — nc=1 deepens real congestion,
|
||||
/// while nc=0 reads random loss as congestion and its RTO backoff drops cwnd to 1. Undecidable
|
||||
/// without a shaped link, so keep what users run today.
|
||||
#[inline]
|
||||
pub fn get_kcp_cc_enabled() -> bool {
|
||||
let k = keys::OPTION_ALLOW_KCP_CC;
|
||||
config::option2bool(k, &Config::get_option(k))
|
||||
}
|
||||
|
||||
// this crate https://github.com/yoshd/stun-client supports nat type
|
||||
async fn stun_ipv6_test(stun_server: &str) -> ResultType<(SocketAddr, String)> {
|
||||
use std::net::ToSocketAddrs;
|
||||
async fn stun_ipv6_test(stun_server: String) -> ResultType<(SocketAddr, String)> {
|
||||
use stunclient::StunClient;
|
||||
let local_addr = SocketAddr::from(([0u16; 8], 0)); // [::]:0
|
||||
let socket = UdpSocket::bind(&local_addr).await?;
|
||||
let Some(stun_addr) = stun_server
|
||||
.to_socket_addrs()?
|
||||
.filter(|x| x.is_ipv6())
|
||||
.next()
|
||||
// Resolve via tokio so DNS never blocks the async runtime worker.
|
||||
let Some(stun_addr) = tokio::net::lookup_host(&stun_server)
|
||||
.await?
|
||||
.find(|x| x.is_ipv6())
|
||||
else {
|
||||
bail!(
|
||||
"Failed to resolve STUN ipv6 server address: {}",
|
||||
@@ -2451,81 +2488,36 @@ async fn stun_ipv6_test(stun_server: &str) -> ResultType<(SocketAddr, String)> {
|
||||
let client = StunClient::new(stun_addr);
|
||||
let addr = client.query_external_address_async(&socket).await?;
|
||||
Ok(if addr.ip().is_ipv6() {
|
||||
(addr, stun_server.to_owned())
|
||||
(addr, stun_server)
|
||||
} else {
|
||||
bail!("STUN server returned non-IPv6 address: {}", addr)
|
||||
})
|
||||
}
|
||||
|
||||
async fn stun_ipv4_test(stun_server: &str) -> ResultType<(SocketAddr, String)> {
|
||||
use std::net::ToSocketAddrs;
|
||||
use stunclient::StunClient;
|
||||
let local_addr = SocketAddr::from(([0u8; 4], 0));
|
||||
let socket = UdpSocket::bind(&local_addr).await?;
|
||||
let Some(stun_addr) = stun_server
|
||||
.to_socket_addrs()?
|
||||
.filter(|x| x.is_ipv4())
|
||||
.next()
|
||||
else {
|
||||
bail!(
|
||||
"Failed to resolve STUN ipv4 server address: {}",
|
||||
stun_server
|
||||
);
|
||||
};
|
||||
let client = StunClient::new(stun_addr);
|
||||
let addr = client.query_external_address_async(&socket).await?;
|
||||
Ok(if addr.ip().is_ipv4() {
|
||||
(addr, stun_server.to_owned())
|
||||
} else {
|
||||
bail!("STUN server returned non-IPv6 address: {}", addr)
|
||||
})
|
||||
}
|
||||
|
||||
static STUNS_V4: [&str; 3] = [
|
||||
"stun.l.google.com:19302",
|
||||
"stun.cloudflare.com:3478",
|
||||
"stun.nextcloud.com:3478",
|
||||
];
|
||||
|
||||
static STUNS_V6: [&str; 3] = [
|
||||
"stun.l.google.com:19302",
|
||||
"stun.cloudflare.com:3478",
|
||||
"stun.nextcloud.com:3478",
|
||||
];
|
||||
|
||||
pub async fn test_nat_ipv4() -> ResultType<(SocketAddr, String)> {
|
||||
use hbb_common::futures::future::{select_ok, FutureExt};
|
||||
let tests = STUNS_V4
|
||||
.iter()
|
||||
.map(|&stun| stun_ipv4_test(stun).boxed())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match select_ok(tests).await {
|
||||
Ok(res) => {
|
||||
return Ok(res.0);
|
||||
}
|
||||
Err(e) => {
|
||||
bail!(
|
||||
"Failed to get public IPv4 address via public STUN servers: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async fn test_bind_ipv6() -> ResultType<SocketAddr> {
|
||||
use hbb_common::futures::future::FutureExt;
|
||||
let local_addr = SocketAddr::from(([0u16; 8], 0)); // [::]:0
|
||||
let socket = UdpSocket::bind(local_addr).await?;
|
||||
let addr = STUNS_V6[0]
|
||||
.to_socket_addrs()?
|
||||
.filter(|x| x.is_ipv6())
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Failed to resolve STUN ipv6 server address: {}",
|
||||
STUNS_V6[0]
|
||||
)
|
||||
})?;
|
||||
// Nothing is sent - `connect` only makes the kernel pick a route and a source address - so any
|
||||
// resolvable target answers equally and the whole cost is DNS. Race the lookups rather than
|
||||
// walk them: this is awaited inline on the connection path, not every STUN host publishes a
|
||||
// AAAA, and one resolver that hangs must not decide whether this host has v6.
|
||||
let lookups = hbb_common::webrtc::WebRTCStream::default_stun_servers()
|
||||
.into_iter()
|
||||
.map(|stun| {
|
||||
(async move {
|
||||
let addr = tokio::net::lookup_host(&stun)
|
||||
.await?
|
||||
.find(|x| x.is_ipv6())
|
||||
.ok_or_else(|| {
|
||||
anyhow!("Failed to resolve STUN ipv6 server address: {}", stun)
|
||||
})?;
|
||||
Ok::<SocketAddr, hbb_common::anyhow::Error>(addr)
|
||||
})
|
||||
.boxed()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let (addr, _) = hbb_common::futures::future::select_ok(lookups).await?;
|
||||
socket.connect(addr).await?;
|
||||
Ok(socket.local_addr()?)
|
||||
}
|
||||
@@ -2592,9 +2584,9 @@ pub async fn test_ipv6() -> Option<tokio::task::JoinHandle<()>> {
|
||||
|
||||
Some(tokio::spawn(async {
|
||||
use hbb_common::futures::future::{select_ok, FutureExt};
|
||||
let tests = STUNS_V6
|
||||
.iter()
|
||||
.map(|&stun| stun_ipv6_test(stun).boxed())
|
||||
let tests = hbb_common::webrtc::WebRTCStream::default_stun_servers()
|
||||
.into_iter()
|
||||
.map(|stun| stun_ipv6_test(stun).boxed())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match select_ok(tests).await {
|
||||
@@ -2615,51 +2607,114 @@ pub async fn test_ipv6() -> Option<tokio::task::JoinHandle<()>> {
|
||||
}))
|
||||
}
|
||||
|
||||
// A punch packet carries a magic and a transaction id so a reply can be *proven* to answer this
|
||||
// probe. The punch it replaces sent a zero-length datagram and called the hole open on whatever
|
||||
// arrived next - which the rendezvous NAT test's own leftover replies satisfied instantly, so the
|
||||
// retry loop below never actually ran and its success meant nothing.
|
||||
const PUNCH_PROBE: [u8; 4] = *b"RDP?";
|
||||
const PUNCH_ACK: [u8; 4] = *b"RDP!";
|
||||
const PUNCH_PACKET_LEN: usize = 12;
|
||||
|
||||
fn punch_packet(tag: &[u8; 4], tid: u64) -> [u8; PUNCH_PACKET_LEN] {
|
||||
let mut packet = [0u8; PUNCH_PACKET_LEN];
|
||||
packet[..4].copy_from_slice(tag);
|
||||
packet[4..].copy_from_slice(&tid.to_le_bytes());
|
||||
packet
|
||||
}
|
||||
|
||||
fn punch_tid(packet: &[u8], tag: &[u8; 4]) -> Option<u64> {
|
||||
if packet.len() != PUNCH_PACKET_LEN || packet[..4] != tag[..] {
|
||||
return None;
|
||||
}
|
||||
packet[4..].try_into().ok().map(u64::from_le_bytes)
|
||||
}
|
||||
|
||||
/// Punch until one of our own probes is acknowledged. Both ends run this identically - each
|
||||
/// probes, each answers the other's probes - and each returns only once a reply carrying its own
|
||||
/// transaction id comes back, the one thing that proves the pair carries traffic both ways.
|
||||
///
|
||||
/// Returning is therefore a fact rather than a guess, which is what lets the caller stop instead
|
||||
/// of handing a dead socket to a transport whose only way to discover the truth is to time out.
|
||||
///
|
||||
/// A datagram that is neither probe nor acknowledgement is returned rather than dropped: it means
|
||||
/// the peer finished first and is already speaking KCP, whose SYN is never retransmitted.
|
||||
///
|
||||
/// Only the connector stops on its own acknowledgement, because only it has something to send
|
||||
/// next. An acknowledgement proves our probe came back, not that the peer's probe was answered -
|
||||
/// and after this returns nothing answers probes any more, since KCP's io loop drops anything
|
||||
/// shorter than its header. A listener that stopped here would go mute while a peer whose own
|
||||
/// probe or answer was lost - the normal state of a hole that is still opening - kept probing an
|
||||
/// endpoint that works, until it timed out. So the listener stops on the peer's first real packet.
|
||||
pub async fn punch_udp(
|
||||
socket: Arc<UdpSocket>,
|
||||
listen: bool,
|
||||
) -> ResultType<Option<bytes::BytesMut>> {
|
||||
let tid = ((hbb_common::time_based_rand() as u64) << 32) | hbb_common::time_based_rand() as u64;
|
||||
let probe = punch_packet(&PUNCH_PROBE, tid);
|
||||
let mut data = [0u8; 1500];
|
||||
// `connect` does not flush the receive queue, so the NAT test's extra replies are still in it.
|
||||
while socket.try_recv(&mut data).is_ok() {}
|
||||
|
||||
let mut retry_interval = Duration::from_millis(20);
|
||||
const MAX_INTERVAL: Duration = Duration::from_millis(200);
|
||||
const MAX_TIME: Duration = Duration::from_secs(20);
|
||||
let mut packets_sent = 0;
|
||||
socket.send(&[]).await.ok();
|
||||
packets_sent += 1;
|
||||
// Both ends start within one rendezvous round trip of each other and the acknowledgement is
|
||||
// one peer round trip, so a pair that has not answered in this long is not going to. The old
|
||||
// 20s came from having no way to tell "not yet" from "never".
|
||||
const MAX_TIME: Duration = Duration::from_secs(3);
|
||||
let mut probes_sent = 0u32;
|
||||
let mut probes_seen = 0u32;
|
||||
let mut acked = false;
|
||||
let mut recv_errors = 0u32;
|
||||
socket.send(&probe).await.ok();
|
||||
probes_sent += 1;
|
||||
let mut last_send_time = Instant::now();
|
||||
let tm = Instant::now();
|
||||
let mut data = [0u8; 1500];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = hbb_common::sleep(retry_interval.as_secs_f32()) => {
|
||||
if tm.elapsed() > MAX_TIME {
|
||||
bail!("UDP punch is timed out, stop sending packets after {:?} packets", packets_sent);
|
||||
bail!("UDP punch is timed out, {probes_sent} probes sent, {probes_seen} probes received, acked: {acked}, {recv_errors} recv errors absorbed");
|
||||
}
|
||||
let elapsed = last_send_time.elapsed();
|
||||
|
||||
if elapsed >= retry_interval {
|
||||
socket.send(&[]).await.ok();
|
||||
packets_sent += 1;
|
||||
|
||||
// Exponentially increase interval to reduce network pressure
|
||||
retry_interval = std::cmp::min(
|
||||
Duration::from_millis((retry_interval.as_millis() as f64 * 1.5) as u64),
|
||||
MAX_INTERVAL
|
||||
);
|
||||
if last_send_time.elapsed() >= retry_interval {
|
||||
socket.send(&probe).await.ok();
|
||||
probes_sent += 1;
|
||||
retry_interval = std::cmp::min(retry_interval.mul_f64(1.5), MAX_INTERVAL);
|
||||
last_send_time = Instant::now();
|
||||
}
|
||||
}
|
||||
res = socket.recv(&mut data) => match res {
|
||||
Err(e) => bail!("UDP punch failed, {packets_sent} packets sent: {e}"),
|
||||
Err(e) => {
|
||||
// ICMP unreachable from the peer's NAT is expected while the hole forms and
|
||||
// surfaces here as ConnectionReset/Refused; treat it as loss, MAX_TIME bounds
|
||||
// the attempt. Log only the first - this retries every 10ms.
|
||||
recv_errors += 1;
|
||||
if recv_errors == 1 {
|
||||
log::debug!("UDP punch recv error (treated as loss): {e}");
|
||||
}
|
||||
hbb_common::sleep(0.01).await;
|
||||
}
|
||||
Ok(n) => {
|
||||
// log::debug!("UDP punch succeeded after sending {} packets after {:?}", packets_sent, tm.elapsed());
|
||||
if listen {
|
||||
if n == 0 {
|
||||
continue;
|
||||
let ack = punch_tid(&data[..n], &PUNCH_ACK);
|
||||
if ack == Some(tid) {
|
||||
if !listen {
|
||||
log::debug!(
|
||||
"UDP punch confirmed in {:?}, {probes_sent} probes sent, {probes_seen} received",
|
||||
tm.elapsed()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
acked = true;
|
||||
} else if let Some(peer_tid) = punch_tid(&data[..n], &PUNCH_PROBE) {
|
||||
probes_seen += 1;
|
||||
socket.send(&punch_packet(&PUNCH_ACK, peer_tid)).await.ok();
|
||||
} else if ack.is_none() && n > 0 {
|
||||
log::debug!(
|
||||
"UDP punch confirmed by {n} bytes of peer data in {:?}, {probes_sent} probes sent",
|
||||
tm.elapsed()
|
||||
);
|
||||
return Ok(Some(bytes::BytesMut::from(&data[..n])));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
129
src/ipc/auth.rs
129
src/ipc/auth.rs
@@ -24,7 +24,6 @@ use std::os::windows::io::AsRawHandle;
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Mutex, OnceLock},
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::{Foundation::HANDLE, System::Pipes::GetNamedPipeClientProcessId};
|
||||
@@ -520,66 +519,17 @@ pub(crate) fn ensure_peer_executable_matches_current_by_fd(
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
const UNAUTHORIZED_IPC_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
#[derive(Default)]
|
||||
struct UnauthorizedIpcLogThrottle {
|
||||
last_log_at: Option<std::time::Instant>,
|
||||
suppressed: u64,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
impl UnauthorizedIpcLogThrottle {
|
||||
#[inline]
|
||||
fn on_reject(&mut self, now: std::time::Instant) -> Option<u64> {
|
||||
if let Some(last) = self.last_log_at {
|
||||
if now.saturating_duration_since(last) < UNAUTHORIZED_IPC_LOG_INTERVAL {
|
||||
self.suppressed += 1;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
self.last_log_at = Some(now);
|
||||
Some(std::mem::take(&mut self.suppressed))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
#[inline]
|
||||
fn throttled_unauthorized_ipc_log(
|
||||
throttle_cell: &OnceLock<Mutex<UnauthorizedIpcLogThrottle>>,
|
||||
emit: impl FnOnce(u64),
|
||||
) {
|
||||
let throttle = throttle_cell.get_or_init(|| Mutex::new(UnauthorizedIpcLogThrottle::default()));
|
||||
let should_log = match throttle.lock() {
|
||||
Ok(mut throttle) => throttle.on_reject(std::time::Instant::now()),
|
||||
Err(_) => Some(0),
|
||||
};
|
||||
if let Some(suppressed) = should_log {
|
||||
emit(suppressed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[inline]
|
||||
fn log_rejected_service_connection(postfix: &str, peer_uid: Option<u32>, active_uid: Option<u32>) {
|
||||
static LOG_THROTTLE: OnceLock<Mutex<UnauthorizedIpcLogThrottle>> = OnceLock::new();
|
||||
throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid,
|
||||
suppressed
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?}",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid
|
||||
);
|
||||
}
|
||||
});
|
||||
hbb_common::throttled_log!(
|
||||
UNAUTHORIZED_IPC_LOG_INTERVAL,
|
||||
warn,
|
||||
"Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?}",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -589,25 +539,14 @@ pub(crate) fn log_rejected_uinput_connection(
|
||||
peer_uid: Option<u32>,
|
||||
active_uid: Option<u32>,
|
||||
) {
|
||||
static LOG_THROTTLE: OnceLock<Mutex<UnauthorizedIpcLogThrottle>> = OnceLock::new();
|
||||
throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid,
|
||||
suppressed
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?}",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid
|
||||
);
|
||||
}
|
||||
});
|
||||
hbb_common::throttled_log!(
|
||||
UNAUTHORIZED_IPC_LOG_INTERVAL,
|
||||
warn,
|
||||
"Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?}",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -620,31 +559,17 @@ pub(crate) fn log_rejected_windows_ipc_connection(
|
||||
peer_is_system: Option<bool>,
|
||||
peer_is_elevated: Option<bool>,
|
||||
) {
|
||||
static LOG_THROTTLE: OnceLock<Mutex<UnauthorizedIpcLogThrottle>> = OnceLock::new();
|
||||
throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?} (suppressed {} similar events)",
|
||||
postfix,
|
||||
peer_pid,
|
||||
peer_session_id,
|
||||
expected_session_id,
|
||||
peer_is_system,
|
||||
peer_is_elevated,
|
||||
suppressed
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?}",
|
||||
postfix,
|
||||
peer_pid,
|
||||
peer_session_id,
|
||||
expected_session_id,
|
||||
peer_is_system,
|
||||
peer_is_elevated
|
||||
);
|
||||
}
|
||||
});
|
||||
hbb_common::throttled_log!(
|
||||
UNAUTHORIZED_IPC_LOG_INTERVAL,
|
||||
warn,
|
||||
"Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?}",
|
||||
postfix,
|
||||
peer_pid,
|
||||
peer_session_id,
|
||||
expected_session_id,
|
||||
peer_is_system,
|
||||
peer_is_elevated
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
|
||||
@@ -19,7 +19,28 @@ pub struct KcpStream {
|
||||
stop_sender: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
const KCP_IO_ERR_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
static KCP_SEND_ERR_LOG: hbb_common::log_throttle::LogThrottle =
|
||||
hbb_common::log_throttle::LogThrottle::new(KCP_IO_ERR_LOG_INTERVAL);
|
||||
static KCP_RECV_ERR_LOG: hbb_common::log_throttle::LogThrottle =
|
||||
hbb_common::log_throttle::LogThrottle::new(KCP_IO_ERR_LOG_INTERVAL);
|
||||
|
||||
impl KcpStream {
|
||||
// Opt in to KCP's built-in congestion window (nc=0) instead of the pure turbo profile
|
||||
// (nc=1) that has always shipped; see `get_kcp_cc_enabled` for why this is not the default.
|
||||
// Sender-side only, so no wire negotiation is needed and either peer may run either profile.
|
||||
// Requires kcp-sys from the `rustdesk-patches` branch, which wires the config factory into
|
||||
// connection setup (on older revs the factory was stored but never consulted).
|
||||
fn apply_kcp_config(endpoint: &mut KcpEndpoint) {
|
||||
if crate::get_kcp_cc_enabled() {
|
||||
endpoint.set_kcp_config_factory(Box::new(|conv| {
|
||||
let mut config = kcp_sys::ffi_safe::KcpConfig::new_turbo(conv);
|
||||
config.nc = Some(0);
|
||||
config
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn create_framed(stream: stream::KcpStream, local_addr: Option<SocketAddr>) -> Stream {
|
||||
Stream::Tcp(FramedStream(
|
||||
tokio_util::codec::Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()),
|
||||
@@ -35,6 +56,7 @@ impl KcpStream {
|
||||
init_packet: Option<BytesMut>,
|
||||
) -> ResultType<(Self, Stream)> {
|
||||
let mut endpoint = KcpEndpoint::new();
|
||||
Self::apply_kcp_config(&mut endpoint);
|
||||
endpoint.run().await;
|
||||
|
||||
let (input, output) = (
|
||||
@@ -70,6 +92,7 @@ impl KcpStream {
|
||||
timeout: std::time::Duration,
|
||||
) -> ResultType<(Self, Stream)> {
|
||||
let mut endpoint = KcpEndpoint::new();
|
||||
Self::apply_kcp_config(&mut endpoint);
|
||||
endpoint.run().await;
|
||||
|
||||
let (input, output) = (
|
||||
@@ -104,6 +127,10 @@ impl KcpStream {
|
||||
let udp = udp_socket.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0; 1500];
|
||||
// Socket errors are ICMP unreachable on a connected UDP socket — advisory, and
|
||||
// routine while a hole forms — so treat them as loss and let KCP's pong timeout reap
|
||||
// a link that is really dead. One throttle PER DIRECTION: the error is reported once
|
||||
// and cleared, so send-ok/recv-err alternates and a shared counter never fires.
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut stop_receiver => {
|
||||
@@ -112,8 +139,10 @@ impl KcpStream {
|
||||
}
|
||||
Some(data) = output.recv() => {
|
||||
if let Err(e) = udp.send(&data.inner()).await {
|
||||
log::debug!("KCP send error: {:?}", e);
|
||||
break;
|
||||
if let Some(n) = KCP_SEND_ERR_LOG.due() {
|
||||
log::debug!("KCP send error x{n} (treated as loss), last: {e}");
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
result = udp.recv_from(&mut buf) => {
|
||||
@@ -127,8 +156,10 @@ impl KcpStream {
|
||||
.await.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("KCP recv_from error: {:?}", e);
|
||||
break;
|
||||
if let Some(n) = KCP_RECV_ERR_LOG.due() {
|
||||
log::debug!("KCP recv error x{n} (treated as loss), last: {e}");
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,3 +180,124 @@ impl Drop for KcpStream {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
async fn connected_pair() -> (Arc<UdpSocket>, Arc<UdpSocket>) {
|
||||
let a = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let b = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
a.connect(b.local_addr().unwrap()).await.unwrap();
|
||||
b.connect(a.local_addr().unwrap()).await.unwrap();
|
||||
(Arc::new(a), Arc::new(b))
|
||||
}
|
||||
|
||||
async fn establish() -> ((KcpStream, Stream), (KcpStream, Stream)) {
|
||||
let (a, b) = connected_pair().await;
|
||||
let (accept_res, connect_res) = tokio::join!(
|
||||
KcpStream::accept(b, Duration::from_secs(5), None),
|
||||
KcpStream::connect(a, Duration::from_secs(5))
|
||||
);
|
||||
(
|
||||
connect_res.expect("connect over loopback"),
|
||||
accept_res.expect("accept over loopback"),
|
||||
)
|
||||
}
|
||||
|
||||
// The full client path over real loopback sockets: handshake through the kcp_io
|
||||
// pumps, framed data both ways, then a graceful close. The endpoint guard stays
|
||||
// alive across the stream drop so the FIN can go out, and the peer's framed
|
||||
// stream must end (BrokenPipe from the kcp reader) instead of hanging.
|
||||
#[tokio::test]
|
||||
async fn test_kcp_stream_loopback_roundtrip_and_close() {
|
||||
let ((_guard_a, mut stream_a), (_guard_b, mut stream_b)) = establish().await;
|
||||
|
||||
stream_a
|
||||
.send_bytes(Bytes::from_static(b"ping"))
|
||||
.await
|
||||
.unwrap();
|
||||
let got = stream_b.next_timeout(5000).await.unwrap().unwrap();
|
||||
assert_eq!(&got[..], b"ping");
|
||||
|
||||
stream_b
|
||||
.send_bytes(Bytes::from_static(b"pong"))
|
||||
.await
|
||||
.unwrap();
|
||||
let got = stream_a.next_timeout(5000).await.unwrap().unwrap();
|
||||
assert_eq!(&got[..], b"pong");
|
||||
|
||||
drop(stream_a);
|
||||
match stream_b.next_timeout(10_000).await {
|
||||
None | Some(Err(_)) => {}
|
||||
Some(Ok(data)) => panic!("unexpected data after close: {:?}", data),
|
||||
}
|
||||
}
|
||||
|
||||
// A writer that queues many frames and closes immediately must not cost the
|
||||
// reader any of them: every frame arrives intact, in order, before end-of-stream.
|
||||
// This is the client-side pin for the kcp-sys close-tail-drain semantics, through
|
||||
// the real BytesCodec framing rustdesk sessions use.
|
||||
#[tokio::test]
|
||||
async fn test_kcp_stream_close_delivers_all_frames() {
|
||||
let ((_guard_a, mut tx), (_guard_b, mut rx)) = establish().await;
|
||||
|
||||
const N: usize = 50;
|
||||
let payload = vec![7u8; 32 * 1024];
|
||||
for _ in 0..N {
|
||||
tx.send_bytes(Bytes::from(payload.clone())).await.unwrap();
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
let mut got = 0usize;
|
||||
loop {
|
||||
match rx.next_timeout(10_000).await {
|
||||
Some(Ok(data)) => {
|
||||
assert_eq!(data.len(), payload.len(), "frame boundary broken");
|
||||
assert!(data.iter().all(|&b| b == 7), "frame content corrupted");
|
||||
got += 1;
|
||||
}
|
||||
// BrokenPipe (kcp reader end) or timeout-None both end the stream.
|
||||
None | Some(Err(_)) => break,
|
||||
}
|
||||
}
|
||||
assert_eq!(got, N, "graceful close lost frames");
|
||||
}
|
||||
|
||||
// Socket errors on the connected UDP socket (ICMP unreachable after the peer
|
||||
// vanishes) are advisory: the io loop must treat them as loss - keep accepting
|
||||
// writes, keep running - rather than tearing the session down. Whether the OS
|
||||
// actually surfaces ECONNREFUSED here is platform-dependent; either way the
|
||||
// session must stay alive for this window.
|
||||
#[tokio::test]
|
||||
async fn test_kcp_io_treats_socket_errors_as_loss() {
|
||||
let ((_guard_a, mut stream_a), (guard_b, stream_b)) = establish().await;
|
||||
|
||||
// Kill the peer entirely: endpoint stops, socket closes.
|
||||
drop(stream_b);
|
||||
drop(guard_b);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
for _ in 0..10 {
|
||||
stream_a
|
||||
.send_bytes(Bytes::from_static(b"into the void"))
|
||||
.await
|
||||
.expect("socket errors must be treated as loss, not stream failure");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// The connect deadline must hold when nothing answers: no hang, prompt error.
|
||||
#[tokio::test]
|
||||
async fn test_kcp_connect_timeout_without_peer() {
|
||||
let (a, _b) = connected_pair().await;
|
||||
let start = tokio::time::Instant::now();
|
||||
let res = KcpStream::connect(a, Duration::from_millis(600)).await;
|
||||
assert!(res.is_err(), "connect must fail with no peer endpoint");
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(5),
|
||||
"connect did not honor its deadline"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "قفل اللوحة"),
|
||||
("Sync clipboard between sessions", "مزامنة الحافظة بين الجلسات"),
|
||||
("sync-clipboard-between-sessions-tip", "النص أو الصور المنسوخة في جلسة بعيدة واحدة تُرسَل أيضًا إلى حافظة جلساتك المتصلة الأخرى."),
|
||||
("Enable WebRTC P2P connection", "تمكين اتصال نظير إلى نظير عبر WebRTC"),
|
||||
("Enable TCP hole punching", "تمكين تقنية حفر الثغرات عبر TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Заблакіраваць палатно"),
|
||||
("Sync clipboard between sessions", "Сінхранізаваць буфер абмену паміж сеансамі"),
|
||||
("sync-clipboard-between-sessions-tip", "Тэкст або відарысы, скапіяваныя ў адным аддаленым сеансе, таксама адпраўляюцца ў буфер абмену іншых вашых падключаных сеансаў."),
|
||||
("Enable WebRTC P2P connection", "Выкарыстоўваць падключэнне WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Выкарыстоўваць TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Заключване на платното"),
|
||||
("Sync clipboard between sessions", "Синхронизиране на клипборда между сесиите"),
|
||||
("sync-clipboard-between-sessions-tip", "Текст или изображения, копирани в една отдалечена сесия, се изпращат и към клипборда на другите ви свързани сесии."),
|
||||
("Enable WebRTC P2P connection", "Позволяване на WebRTC P2P връзка"),
|
||||
("Enable TCP hole punching", "Позволяване на TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloca el llenç"),
|
||||
("Sync clipboard between sessions", "Sincronitza el porta-retalls entre sessions"),
|
||||
("sync-clipboard-between-sessions-tip", "El text o les imatges copiats en una sessió remota també s'envien al porta-retalls de les altres sessions connectades."),
|
||||
("Enable WebRTC P2P connection", "Habilita la connexió WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Activa la perforació TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"),
|
||||
("screenshot-action-tip", "请选择如何继续截屏。"),
|
||||
("Save as", "另存为"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "导出"),
|
||||
("Export Logs", "导出日志"),
|
||||
("Import Folder", "导入文件夹"),
|
||||
("Copy to clipboard", "复制到剪贴板"),
|
||||
("Enable remote printer", "启用远程打印机"),
|
||||
("Downloading {}", "正在下载 {}"),
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "锁定画布"),
|
||||
("Sync clipboard between sessions", "在会话间同步剪贴板"),
|
||||
("sync-clipboard-between-sessions-tip", "在一个远程会话中复制的文本或图片也会发送到其他已连接会话的剪贴板。"),
|
||||
("Enable WebRTC P2P connection", "启用 WebRTC P2P 连接"),
|
||||
("Enable TCP hole punching", "启用 TCP 打洞"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Zamknout zobrazení"),
|
||||
("Sync clipboard between sessions", "Synchronizovat schránku mezi relacemi"),
|
||||
("sync-clipboard-between-sessions-tip", "Text nebo obrázky zkopírované v jedné vzdálené relaci se odešlou i do schránky ostatních připojených relací."),
|
||||
("Enable WebRTC P2P connection", "Povolit připojení WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Povolit TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Lås lærred"),
|
||||
("Sync clipboard between sessions", "Synkroniser udklipsholder mellem sessioner"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst eller billeder, der kopieres i én fjernsession, sendes også til udklipsholderen i dine andre forbundne sessioner."),
|
||||
("Enable WebRTC P2P connection", "Aktivér WebRTC P2P-forbindelse"),
|
||||
("Enable TCP hole punching", "Aktivér TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Sichtfeld sperren"),
|
||||
("Sync clipboard between sessions", "Zwischenablage zwischen Sitzungen synchronisieren"),
|
||||
("sync-clipboard-between-sessions-tip", "In einer Remote-Sitzung kopierter Text oder kopierte Bilder werden auch an die Zwischenablage Ihrer anderen verbundenen Sitzungen gesendet."),
|
||||
("Enable WebRTC P2P connection", "WebRTC-P2P-Verbindung aktivieren"),
|
||||
("Enable TCP hole punching", "TCP-Hole-Punching aktivieren"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Κλείδωμα καμβά"),
|
||||
("Sync clipboard between sessions", "Συγχρονισμός προχείρου μεταξύ συνεδριών"),
|
||||
("sync-clipboard-between-sessions-tip", "Κείμενο ή εικόνες που αντιγράφονται σε μία απομακρυσμένη συνεδρία αποστέλλονται και στο πρόχειρο των άλλων συνδεδεμένων συνεδριών σας."),
|
||||
("Enable WebRTC P2P connection", "Ενεργοποίηση σύνδεσης WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Ενεργοποίηση διάτρησης οπών TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Ŝlosi kanvason"),
|
||||
("Sync clipboard between sessions", "Sinkronigi poŝon inter seancoj"),
|
||||
("sync-clipboard-between-sessions-tip", "Teksto aŭ bildoj kopiitaj en unu fora seanco ankaŭ sendiĝas al la poŝo de viaj aliaj konektitaj seancoj."),
|
||||
("Enable WebRTC P2P connection", "Ebligi WebRTC P2P-konekton"),
|
||||
("Enable TCP hole punching", "Ebligi TCP-trapikadon"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloquear lienzo"),
|
||||
("Sync clipboard between sessions", "Sincronizar portapapeles entre sesiones"),
|
||||
("sync-clipboard-between-sessions-tip", "El texto o las imágenes copiados en una sesión remota también se envían al portapapeles de tus otras sesiones conectadas."),
|
||||
("Enable WebRTC P2P connection", "Habilitar conexión WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Habilitar perforación de agujero TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Lukusta lõuend"),
|
||||
("Sync clipboard between sessions", "Sünkrooni lõikelaud seansside vahel"),
|
||||
("sync-clipboard-between-sessions-tip", "Ühes kaugseansis kopeeritud tekst või pildid saadetakse ka teiste ühendatud seansside lõikelauale."),
|
||||
("Enable WebRTC P2P connection", "Luba WebRTC P2P-ühendus"),
|
||||
("Enable TCP hole punching", "Luba TCP-augustamine"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Blokeatu oihala"),
|
||||
("Sync clipboard between sessions", "Sinkronizatu arbela saioen artean"),
|
||||
("sync-clipboard-between-sessions-tip", "Urruneko saio batean kopiatutako testua edo irudiak konektatutako beste saioen arbelera ere bidaltzen dira."),
|
||||
("Enable WebRTC P2P connection", "Gaitu WebRTC P2P konexioa"),
|
||||
("Enable TCP hole punching", "Gaitu TCP zulo-egitea"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "قفل کردن صفحه"),
|
||||
("Sync clipboard between sessions", "همگامسازی کلیپبورد بین نشستها"),
|
||||
("sync-clipboard-between-sessions-tip", "متن یا تصاویری که در یک نشست راه دور کپی میشوند به کلیپبورد سایر نشستهای متصل شما نیز ارسال میشوند."),
|
||||
("Enable WebRTC P2P connection", "فعالسازی اتصال همتابههمتای WebRTC"),
|
||||
("Enable TCP hole punching", "فعالسازی تکنیک TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Lukitse näkymä"),
|
||||
("Sync clipboard between sessions", "Synkronoi leikepöytä istuntojen välillä"),
|
||||
("sync-clipboard-between-sessions-tip", "Yhdessä etäistunnossa kopioitu teksti tai kuvat lähetetään myös muiden yhdistettyjen istuntojen leikepöydälle."),
|
||||
("Enable WebRTC P2P connection", "Ota WebRTC P2P yhteys käyttöön"),
|
||||
("Enable TCP hole punching", "Ota käyttöön TCP hole punching tekniikka"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Verrouiller la vue"),
|
||||
("Sync clipboard between sessions", "Synchroniser le presse-papiers entre les sessions"),
|
||||
("sync-clipboard-between-sessions-tip", "Le texte ou les images copiés dans une session distante sont également envoyés au presse-papiers de vos autres sessions connectées."),
|
||||
("Enable WebRTC P2P connection", "Activer la connexion P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Activer le « hole punching » TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "ტილოს დაბლოკვა"),
|
||||
("Sync clipboard between sessions", "გაცვლის ბუფერის სინქრონიზაცია სესიებს შორის"),
|
||||
("sync-clipboard-between-sessions-tip", "ერთ დაშორებულ სესიაში დაკოპირებული ტექსტი ან სურათები ასევე იგზავნება თქვენი სხვა დაკავშირებული სესიების გაცვლის ბუფერში."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P კავშირის ჩართვა"),
|
||||
("Enable TCP hole punching", "TCP hole punching-ის ჩართვა"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "કેનવાસ લોક કરો"),
|
||||
("Sync clipboard between sessions", "સત્રો વચ્ચે ક્લિપબોર્ડ સિંક કરો"),
|
||||
("sync-clipboard-between-sessions-tip", "એક રિમોટ સત્રમાં કૉપિ કરેલ ટેક્સ્ટ કે છબીઓ તમારા અન્ય જોડાયેલા સત્રોના ક્લિપબોર્ડ પર પણ મોકલવામાં આવે છે."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P કનેક્શન સક્ષમ કરો"),
|
||||
("Enable TCP hole punching", "TCP હોલ પંચિંગ સક્ષમ કરો"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "נעל לוח ציור"),
|
||||
("Sync clipboard between sessions", "סנכרן לוח בין סשנים"),
|
||||
("sync-clipboard-between-sessions-tip", "טקסט או תמונות שהועתקו בסשן מרוחק אחד נשלחים גם ללוח של שאר הסשנים המחוברים שלך."),
|
||||
("Enable WebRTC P2P connection", "אפשר חיבור WebRTC P2P"),
|
||||
("Enable TCP hole punching", "אפשר TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "कैनवास लॉक करें"),
|
||||
("Sync clipboard between sessions", "सत्रों के बीच क्लिपबोर्ड सिंक करें"),
|
||||
("sync-clipboard-between-sessions-tip", "एक रिमोट सत्र में कॉपी किए गए टेक्स्ट या चित्र आपके अन्य जुड़े सत्रों के क्लिपबोर्ड पर भी भेजे जाते हैं।"),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P कनेक्शन सक्षम करें"),
|
||||
("Enable TCP hole punching", "TCP होल पंचिंग सक्षम करें"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Zaključaj pozadinu"),
|
||||
("Sync clipboard between sessions", "Sinkroniziraj međuspremnik između sesija"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirani u jednoj udaljenoj sesiji šalju se i u međuspremnik vaših ostalih povezanih sesija."),
|
||||
("Enable WebRTC P2P connection", "Omogući WebRTC P2P vezu"),
|
||||
("Enable TCP hole punching", "Omogući TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Nézet zárolása"),
|
||||
("Sync clipboard between sessions", "Vágólap szinkronizálása a munkamenetek között"),
|
||||
("sync-clipboard-between-sessions-tip", "Az egyik távoli munkamenetben másolt szöveg vagy kép a többi csatlakoztatott munkamenet vágólapjára is elküldésre kerül."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P kapcsolat engedélyezése"),
|
||||
("Enable TCP hole punching", "TCP résszűrés engedélyezése"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Kunci kanvas"),
|
||||
("Sync clipboard between sessions", "Sinkronkan papan klip antar sesi"),
|
||||
("sync-clipboard-between-sessions-tip", "Teks atau gambar yang disalin di satu sesi jarak jauh juga dikirim ke papan klip sesi terhubung Anda yang lain."),
|
||||
("Enable WebRTC P2P connection", "Aktifkan koneksi P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Aktifkan TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Blocca tela"),
|
||||
("Sync clipboard between sessions", "Sincronizza gli appunti tra le sessioni"),
|
||||
("sync-clipboard-between-sessions-tip", "Il testo o le immagini copiati in una sessione remota vengono inviati anche agli appunti delle altre sessioni connesse."),
|
||||
("Enable WebRTC P2P connection", "Abilita connessione P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Abilita hole punching TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "キャンバスをロック"),
|
||||
("Sync clipboard between sessions", "セッション間でクリップボードを同期"),
|
||||
("sync-clipboard-between-sessions-tip", "1つのリモートセッションでコピーしたテキストや画像は、接続中の他のセッションのクリップボードにも送信されます。"),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P 接続を有効化する"),
|
||||
("Enable TCP hole punching", "TCP ホールパンチを有効化する"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "캔버스 잠금"),
|
||||
("Sync clipboard between sessions", "세션 간 클립보드 동기화"),
|
||||
("sync-clipboard-between-sessions-tip", "하나의 원격 세션에서 복사한 텍스트나 이미지는 연결된 다른 세션의 클립보드에도 전송됩니다."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P 연결 사용"),
|
||||
("Enable TCP hole punching", "TCP 홀 펀칭 사용"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Кенепті құлыптау"),
|
||||
("Sync clipboard between sessions", "Сеанстар арасында көшіру-тақтасын синхрондау"),
|
||||
("sync-clipboard-between-sessions-tip", "Бір қашықтағы сеанста көшірілген мәтін немесе суреттер басқа қосылған сеанстардың көшіру-тақтасына да жіберіледі."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P қосылымын іске қосу"),
|
||||
("Enable TCP hole punching", "TCP hole punching'ті іске қосу"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Užrakinti drobę"),
|
||||
("Sync clipboard between sessions", "Sinchronizuoti iškarpinę tarp seansų"),
|
||||
("sync-clipboard-between-sessions-tip", "Viename nuotoliniame seanse nukopijuotas tekstas ar vaizdai taip pat siunčiami į kitų prijungtų seansų iškarpinę."),
|
||||
("Enable WebRTC P2P connection", "Įgalinti WebRTC P2P ryšį"),
|
||||
("Enable TCP hole punching", "Įgalinti TCP gręžimą (hole punching)"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloķēt audeklu"),
|
||||
("Sync clipboard between sessions", "Sinhronizēt starpliktuvi starp sesijām"),
|
||||
("sync-clipboard-between-sessions-tip", "Vienā attālajā sesijā nokopētais teksts vai attēli tiek nosūtīti arī uz pārējo pievienoto sesiju starpliktuvi."),
|
||||
("Enable WebRTC P2P connection", "Iespējot WebRTC P2P savienojumu"),
|
||||
("Enable TCP hole punching", "Iespējot TCP caurumu veidošanu"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "ക്യാൻവാസ് ലോക്ക് ചെയ്യുക"),
|
||||
("Sync clipboard between sessions", "സെഷനുകൾക്കിടയിൽ ക്ലിപ്പ്ബോർഡ് സമന്വയിപ്പിക്കുക"),
|
||||
("sync-clipboard-between-sessions-tip", "ഒരു റിമോട്ട് സെഷനിൽ പകർത്തിയ ടെക്സ്റ്റോ ചിത്രങ്ങളോ നിങ്ങളുടെ മറ്റ് കണക്റ്റുചെയ്ത സെഷനുകളുടെ ക്ലിപ്പ്ബോർഡിലേക്കും അയയ്ക്കപ്പെടും."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P കണക്ഷൻ അനുവദിക്കുക"),
|
||||
("Enable TCP hole punching", "TCP ഹോൾ പഞ്ചിംഗ് അനുവദിക്കുക"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Lås lerret"),
|
||||
("Sync clipboard between sessions", "Synkroniser utklippstavlen mellom økter"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst eller bilder som kopieres i én ekstern økt, sendes også til utklippstavlen i de andre tilkoblede øktene dine."),
|
||||
("Enable WebRTC P2P connection", "Aktiver WebRTC P2P-tilkobling"),
|
||||
("Enable TCP hole punching", "Aktiver TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Schermopnames van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."),
|
||||
("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."),
|
||||
("Save as", "Opslaan als"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exporteren"),
|
||||
("Export Logs", "Logboeken exporteren"),
|
||||
("Import Folder", "Map importeren"),
|
||||
("Copy to clipboard", "Kopiëren naar het klembord"),
|
||||
("Enable remote printer", "Printer op afstand inschakelen"),
|
||||
("Downloading {}", "Downloaden {}"),
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Canvas vergrendelen"),
|
||||
("Sync clipboard between sessions", "Klembord synchroniseren tussen sessies"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst of afbeeldingen die in één externe sessie worden gekopieerd, worden ook naar het klembord van uw andere verbonden sessies gestuurd."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P-verbinding inschakelen"),
|
||||
("Enable TCP hole punching", "TCP-hole punching inschakelen"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Zablokuj ekran"),
|
||||
("Sync clipboard between sessions", "Synchronizuj schowek między sesjami"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst lub obrazy skopiowane w jednej sesji zdalnej są wysyłane także do schowka pozostałych połączonych sesji."),
|
||||
("Enable WebRTC P2P connection", "Włącz połączenie P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Włącz tworzenie tunelu TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloquear tela"),
|
||||
("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"),
|
||||
("sync-clipboard-between-sessions-tip", "O texto ou as imagens copiados numa sessão remota também são enviados para a área de transferência das suas outras sessões ligadas."),
|
||||
("Enable WebRTC P2P connection", "Ativar ligação P2P por WebRTC"),
|
||||
("Enable TCP hole punching", "Ativar TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."),
|
||||
("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."),
|
||||
("Save as", "Salvar como"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exportar"),
|
||||
("Export Logs", "Exportar logs"),
|
||||
("Import Folder", "Importar pasta"),
|
||||
("Copy to clipboard", "Copiar para área de transferência"),
|
||||
("Enable remote printer", "Habilitar impressora remota"),
|
||||
("Downloading {}", "Baixando {}"),
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloquear tela"),
|
||||
("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"),
|
||||
("sync-clipboard-between-sessions-tip", "Texto ou imagens copiados em uma sessão remota também são enviados para a área de transferência das suas outras sessões conectadas."),
|
||||
("Enable WebRTC P2P connection", "Habilitar conexão WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Habilitar TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Blochează ecranul"),
|
||||
("Sync clipboard between sessions", "Sincronizează clipboardul între sesiuni"),
|
||||
("sync-clipboard-between-sessions-tip", "Textul sau imaginile copiate într-o sesiune la distanță sunt trimise și în clipboardul celorlalte sesiuni conectate."),
|
||||
("Enable WebRTC P2P connection", "Activează conexiunea P2P prin WebRTC"),
|
||||
("Enable TCP hole punching", "Activează traversarea TCP (hole punching)"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Заблокировать холст"),
|
||||
("Sync clipboard between sessions", "Синхронизировать буфер обмена между сеансами"),
|
||||
("sync-clipboard-between-sessions-tip", "Текст или изображения, скопированные в одном удалённом сеансе, также отправляются в буфер обмена других подключённых сеансов."),
|
||||
("Enable WebRTC P2P connection", "Использовать подключение WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Использовать TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloca sa tela"),
|
||||
("Sync clipboard between sessions", "Sincroniza sa punta de billete intre is sessiones"),
|
||||
("sync-clipboard-between-sessions-tip", "Su testu o is immàgines copiadas in una sessione remota sunt imbiadas fintzas a sa punta de billete de is àteras sessiones connètidas."),
|
||||
("Enable WebRTC P2P connection", "Abìlita connessione P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Abìlita s'istampadura TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Uzamknúť zobrazenie"),
|
||||
("Sync clipboard between sessions", "Synchronizovať schránku medzi reláciami"),
|
||||
("sync-clipboard-between-sessions-tip", "Text alebo obrázky skopírované v jednej vzdialenej relácii sa odošlú aj do schránky ostatných pripojených relácií."),
|
||||
("Enable WebRTC P2P connection", "Povoliť pripojenie WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Povoliť TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Zakleni platno"),
|
||||
("Sync clipboard between sessions", "Sinhroniziraj odložišče med sejami"),
|
||||
("sync-clipboard-between-sessions-tip", "Besedilo ali slike, kopirane v eni oddaljeni seji, se pošljejo tudi v odložišče vaših drugih povezanih sej."),
|
||||
("Enable WebRTC P2P connection", "Omogoči povezavo WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Omogoči preboj lukenj TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Kyç canvas"),
|
||||
("Sync clipboard between sessions", "Sinkronizo clipboard-in midis sesioneve"),
|
||||
("sync-clipboard-between-sessions-tip", "Teksti ose imazhet e kopjuara në një sesion të largët dërgohen edhe në clipboard-in e sesioneve të tjera të lidhura."),
|
||||
("Enable WebRTC P2P connection", "Aktivizo lidhjen WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Aktivizo TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Zaključaj pozadinu"),
|
||||
("Sync clipboard between sessions", "Sinhronizuj klipbord između sesija"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirane u jednoj udaljenoj sesiji šalju se i u klipbord vaših ostalih povezanih sesija."),
|
||||
("Enable WebRTC P2P connection", "Omogući WebRTC P2P konekciju"),
|
||||
("Enable TCP hole punching", "Omogući TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Lås canvas"),
|
||||
("Sync clipboard between sessions", "Synkronisera urklipp mellan sessioner"),
|
||||
("sync-clipboard-between-sessions-tip", "Text eller bilder som kopieras i en fjärrsession skickas även till urklipp i dina andra anslutna sessioner."),
|
||||
("Enable WebRTC P2P connection", "Aktivera WebRTC P2P anslutning"),
|
||||
("Enable TCP hole punching", "Aktivera TCP hålslagning"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "கேன்வாஸைப் பூட்டு"),
|
||||
("Sync clipboard between sessions", "அமர்வுகளுக்கு இடையே கிளிப்போர்டை ஒத்திசைக்கவும்"),
|
||||
("sync-clipboard-between-sessions-tip", "ஒரு தொலை அமர்வில் நகலெடுக்கப்பட்ட உரை அல்லது படங்கள் உங்கள் பிற இணைக்கப்பட்ட அமர்வுகளின் கிளிப்போர்டுக்கும் அனுப்பப்படும்."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P இணைப்பு இயக்கு"),
|
||||
("Enable TCP hole punching", "TCP hole punching இயக்கு"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", ""),
|
||||
("Sync clipboard between sessions", ""),
|
||||
("sync-clipboard-between-sessions-tip", ""),
|
||||
("Enable WebRTC P2P connection", ""),
|
||||
("Enable TCP hole punching", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "ล็อคแคนวาส"),
|
||||
("Sync clipboard between sessions", "ซิงค์คลิปบอร์ดระหว่างเซสชัน"),
|
||||
("sync-clipboard-between-sessions-tip", "ข้อความหรือรูปภาพที่คัดลอกในเซสชันระยะไกลหนึ่งจะถูกส่งไปยังคลิปบอร์ดของเซสชันอื่นที่เชื่อมต่ออยู่ด้วย"),
|
||||
("Enable WebRTC P2P connection", "เปิดใช้งานการเชื่อมต่อ P2P แบบ WebRTC"),
|
||||
("Enable TCP hole punching", "เปิดใช้งาน TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Tuvali kilitle"),
|
||||
("Sync clipboard between sessions", "Oturumlar arasında panoyu senkronize et"),
|
||||
("sync-clipboard-between-sessions-tip", "Bir uzak oturumda kopyalanan metin veya görseller, bağlı diğer oturumlarınızın panosuna da gönderilir."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P bağlantısını etkinleştir"),
|
||||
("Enable TCP hole punching", "TCP delik açmayı etkinleştir"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enter your password", "輸入您的密碼"),
|
||||
("Logging in...", "正在登入..."),
|
||||
("Enable RDP session sharing", "啟用 RDP 工作階段分享"),
|
||||
("Auto Login", "自動登入 (只在您設定「工作階段結束後鎖定」時有效)"),
|
||||
("Auto Login", "自動登入(只在您設定「工作階段結束後鎖定」時有效)"),
|
||||
("Enable direct IP access", "啟用 IP 直接存取"),
|
||||
("Rename", "重新命名"),
|
||||
("Space", "空白"),
|
||||
@@ -300,7 +300,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Language", "語言"),
|
||||
("Keep RustDesk background service", "保持 RustDesk 後台服務"),
|
||||
("Ignore Battery Optimizations", "忽略電池最佳化"),
|
||||
("android_open_battery_optimizations_tip", "如果您想要停用此功能,請前往下一個 RustDesk 應用程式設定頁面,找到並進入「電池」,取消勾選「不受限制」"),
|
||||
("android_open_battery_optimizations_tip", "如果您想要停用此功能,請前往下一個 RustDesk 應用程式設定頁面,找到並進入「電池」,取消勾選「不受限制」。"),
|
||||
("Start on boot", "開機時啟動"),
|
||||
("Start the screen sharing service on boot, requires special permissions", "開機時啟動螢幕分享服務,需要特殊權限。"),
|
||||
("Connection not allowed", "不允許連線"),
|
||||
@@ -519,11 +519,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("I Agree", "同意"),
|
||||
("Decline", "拒絕"),
|
||||
("Timeout in minutes", "超時(分鐘)"),
|
||||
("auto_disconnect_option_tip", "自動在連入的使用者不活躍時關閉工作階段"),
|
||||
("auto_disconnect_option_tip", "自動關閉不活躍的連入工作階段"),
|
||||
("Connection failed due to inactivity", "由於長時間沒有操作,已自動關閉工作階段"),
|
||||
("Check for software update on startup", "啟動時檢查更新"),
|
||||
("upgrade_rustdesk_server_pro_to_{}_tip", "請升級專業版伺服器到{}或更高版本!"),
|
||||
("pull_group_failed_tip", "獲取群組訊息失敗"),
|
||||
("pull_group_failed_tip", "重新整理群組失敗"),
|
||||
("Filter by intersection", "按照交集篩選"),
|
||||
("Remove wallpaper during incoming sessions", "在接受連入連線時移除桌布"),
|
||||
("Test", "測試"),
|
||||
@@ -639,7 +639,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Use D3D rendering", "使用 D3D 渲染"),
|
||||
("Printer", "印表機"),
|
||||
("printer-os-requirement-tip", "印表機的傳出功能需要 Windows 10 或更高版本。"),
|
||||
("printer-requires-installed-{}-client-tip", "為了使用遠端列印功能,請安裝 {} 到此設備。"),
|
||||
("printer-requires-installed-{}-client-tip", "為了使用遠端列印功能,請安裝 {} 到此裝置。"),
|
||||
("printer-{}-not-installed-tip", "{} 印表機未安裝。"),
|
||||
("printer-{}-ready-tip", "{} 印表機已安裝,您可以使用列印功能了。"),
|
||||
("Install {} Printer", "安裝 {} 印表機"),
|
||||
@@ -659,17 +659,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "目前不支援合併多個螢幕的截圖。請切換至單一螢幕後再試。"),
|
||||
("screenshot-action-tip", "請選擇要如何處理這張截圖。"),
|
||||
("Save as", "另存為"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "匯出"),
|
||||
("Export Logs", "匯出日誌"),
|
||||
("Import Folder", "匯入資料夾"),
|
||||
("Copy to clipboard", "複製到剪貼簿"),
|
||||
("Enable remote printer", "啟用遠端列印"),
|
||||
("Downloading {}", "正在下載 {} 並安裝新版本。"),
|
||||
("{} Update", "{} 更新"),
|
||||
("{}-to-update-tip", "即將關閉 {} 並安裝新版本。"),
|
||||
("download-new-version-failed-tip", "下載失敗,您可以重試或點擊\"下載\"按鈕以從發布網址下載,並手動升級。"),
|
||||
("download-new-version-failed-tip", "下載失敗,您可以重試或點選\"下載\"按鈕以從發布網址下載,並手動升級。"),
|
||||
("Auto update", "自動更新"),
|
||||
("update-failed-check-msi-tip", "安裝方式偵測失敗,請點擊\"下載\"按鈕以從發布網址下載,並手動升級。"),
|
||||
("update-failed-check-msi-tip", "安裝方式偵測失敗,請點選\"下載\"按鈕以從發布網址下載,並手動升級。"),
|
||||
("websocket_tip", "使用 WebSocket 時,只支援使用中繼連接。"),
|
||||
("Use WebSocket", "使用 WebSocket"),
|
||||
("Trackpad speed", "觸控板速度"),
|
||||
@@ -680,7 +680,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("View camera", "檢視相機"),
|
||||
("Enable camera", "允許查看鏡頭"),
|
||||
("No cameras", "沒有鏡頭"),
|
||||
("view_camera_unsupported_tip", "您的遠端設備不支援查看鏡頭"),
|
||||
("view_camera_unsupported_tip", "您的遠端裝置不支援查看鏡頭"),
|
||||
("Terminal", "終端機"),
|
||||
("Enable terminal", "啟用終端機"),
|
||||
("New tab", "新分頁"),
|
||||
@@ -690,7 +690,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Failed to get user token.", "取得使用者權杖失敗"),
|
||||
("Incorrect username or password.", "使用者名稱或密碼不正確"),
|
||||
("The user is not an administrator.", "使用者並不是系統管理員"),
|
||||
("Failed to check if the user is an administrator.", "檢查使用者是否是系統管理員時失敗了"),
|
||||
("Failed to check if the user is an administrator.", "無法確認使用者是否為系統管理員"),
|
||||
("Supported only in the installed version.", "僅支援於已安裝的版本"),
|
||||
("elevation_username_tip", "輸入使用者名稱或網域\\使用者名稱"),
|
||||
("Preparing for installation ...", "正在準備安裝..."),
|
||||
@@ -747,21 +747,23 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Show monitor switch button on the main toolbar", "在主工具列上顯示螢幕切換按鈕"),
|
||||
("Show on the minimized toolbar", "在最小化工具列上顯示"),
|
||||
("All monitors", "所有顯示器"),
|
||||
("#{} monitor", "{}號顯示器"),
|
||||
("conn-e2ee-unavailable-tip", "無法驗證端對端加密。\n遠端裝置可能仍在準備中,請稍後重試。\n如果此問題持續發生,伺服器可能不受信任。\n仍要繼續嗎?"),
|
||||
("#{} monitor", "{} 號顯示器"),
|
||||
("conn-e2ee-unavailable-tip", "無法驗證端到端加密。\n遠端裝置可能仍在準備中,請稍後再試。\n如果此問題持續發生,伺服器可能不受信任。\n仍要繼續嗎?"),
|
||||
("ID whitelisting", "ID 白名單"),
|
||||
("Use ID whitelisting", "只允許白名單上的 ID 進行連線"),
|
||||
("id_whitelist_tip", "只有白名單上的 ID 可以存取"),
|
||||
("id_whitelist_wildcard_tip", "支援萬用字元:'*' 符合任意數量的字元,'?' 符合單一字元"),
|
||||
("id_whitelist_wildcard_tip", "支援萬用字元:'*' 以符合任意數量的字元,'?' 以符合單一字元"),
|
||||
("Invalid ID", "ID 無效"),
|
||||
("Your ID is blocked by the peer", "你的 ID 已被對方封鎖"),
|
||||
("Your ip is blocked by the peer", "你的 IP 已被對方封鎖"),
|
||||
("id_whitelist_caveat_tip", "ID 由對端用戶端回報,白名單用於減少暴露面,不能取代密碼或 2FA"),
|
||||
("Your ID is blocked by the peer", "您的 ID 已被對方封鎖"),
|
||||
("Your ip is blocked by the peer", "您的 IP 已被對方封鎖"),
|
||||
("id_whitelist_caveat_tip", "ID 由對端客戶端回報。此白名單用於減少暴露面,不能取代密碼或 2FA。"),
|
||||
("whitelist_cidr_tip", "支援 CIDR 寫法,例如 192.168.1.0/24"),
|
||||
("Continue", "繼續"),
|
||||
("Browser didn't open? Use the url below to sign in.", "瀏覽器未開啟?請使用下方網址登入。"),
|
||||
("Lock canvas", "鎖定畫布"),
|
||||
("Sync clipboard between sessions", "在工作階段間同步剪貼簿"),
|
||||
("sync-clipboard-between-sessions-tip", "在一個遠端工作階段中複製的文字或圖片也會傳送到其他已連線工作階段的剪貼簿。"),
|
||||
("Enable WebRTC P2P connection", "啟用 WebRTC P2P 連線"),
|
||||
("Enable TCP hole punching", "啟用 TCP 打洞"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Блокування полотна"),
|
||||
("Sync clipboard between sessions", "Синхронізувати буфер обміну між сеансами"),
|
||||
("sync-clipboard-between-sessions-tip", "Текст або зображення, скопійовані в одному віддаленому сеансі, також надсилаються до буфера обміну інших підключених сеансів."),
|
||||
("Enable WebRTC P2P connection", "Увімкнути P2P-підключення через WebRTC"),
|
||||
("Enable TCP hole punching", "Увімкнути TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Khóa khung hình"),
|
||||
("Sync clipboard between sessions", "Đồng bộ clipboard giữa các phiên"),
|
||||
("sync-clipboard-between-sessions-tip", "Văn bản hoặc hình ảnh được sao chép trong một phiên từ xa cũng được gửi đến clipboard của các phiên đã kết nối khác."),
|
||||
("Enable WebRTC P2P connection", "Cho phép kết nối WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Bật TCP Hole Punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
404
src/platform/android_ifaddrs.c
Normal file
404
src/platform/android_ifaddrs.c
Normal file
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
* getifaddrs()/freeifaddrs() for Android: bionic only exports them from API 24,
|
||||
* while the jniLibs are built against the API 21 sysroot (flutter/ndk_*.sh) and
|
||||
* webrtc-util calls them whenever WebRTC gathers ICE candidates.
|
||||
*
|
||||
* Only AF_INET and AF_INET6 entries are reported; the AF_PACKET ones the real
|
||||
* getifaddrs() also returns have no reader in this build.
|
||||
*/
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <ifaddrs.h>
|
||||
#include <net/if.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#include <linux/netlink.h>
|
||||
#include <linux/rtnetlink.h>
|
||||
|
||||
/* Refuse a single netlink datagram larger than this rather than grow forever. */
|
||||
#define RD_NL_MAX_BUF (1024 * 1024)
|
||||
/* A dump that never terminates must not hang the caller. */
|
||||
#define RD_NL_MAX_DATAGRAMS 4096
|
||||
|
||||
typedef int (*rd_nl_cb)(struct nlmsghdr *nlh, void *ctx);
|
||||
|
||||
struct rd_link_info {
|
||||
unsigned int index;
|
||||
unsigned int flags;
|
||||
char name[IFNAMSIZ + 1];
|
||||
};
|
||||
|
||||
struct rd_link_table {
|
||||
struct rd_link_info *items;
|
||||
size_t len;
|
||||
size_t cap;
|
||||
};
|
||||
|
||||
/* One allocation per reported address; `ifa` first so freeifaddrs() can free
|
||||
* the node it is handed. */
|
||||
struct rd_ifaddrs_storage {
|
||||
struct ifaddrs ifa;
|
||||
struct sockaddr_storage addr;
|
||||
struct sockaddr_storage netmask;
|
||||
struct sockaddr_storage ifu;
|
||||
char name[IFNAMSIZ + 1];
|
||||
};
|
||||
|
||||
struct rd_addr_ctx {
|
||||
const struct rd_link_table *links;
|
||||
struct ifaddrs *head;
|
||||
struct ifaddrs *tail;
|
||||
};
|
||||
|
||||
static void rd_parse_rtattr(struct rtattr *rta, int len, struct rtattr **tb, int max)
|
||||
{
|
||||
memset(tb, 0, sizeof(*tb) * ((size_t)max + 1));
|
||||
for (; RTA_OK(rta, len); rta = RTA_NEXT(rta, len)) {
|
||||
if (rta->rta_type <= (unsigned short)max && tb[rta->rta_type] == NULL)
|
||||
tb[rta->rta_type] = rta;
|
||||
}
|
||||
}
|
||||
|
||||
/* `len` must stay signed: NLMSG_NEXT subtracts the *aligned* length, which
|
||||
* overshoots on an unaligned trailing message, and only a negative remainder
|
||||
* stops NLMSG_OK from reading past the buffer. */
|
||||
static int rd_nl_parse(char *buf, int len, unsigned short reply_type, unsigned int seq,
|
||||
rd_nl_cb cb, void *ctx, int *done)
|
||||
{
|
||||
struct nlmsghdr *nlh = (struct nlmsghdr *)buf;
|
||||
|
||||
for (; NLMSG_OK(nlh, len); nlh = NLMSG_NEXT(nlh, len)) {
|
||||
if (nlh->nlmsg_seq != seq)
|
||||
continue;
|
||||
if (nlh->nlmsg_type == NLMSG_DONE) {
|
||||
*done = 1;
|
||||
return 0;
|
||||
}
|
||||
if (nlh->nlmsg_type == NLMSG_ERROR) {
|
||||
struct nlmsgerr *err = (struct nlmsgerr *)NLMSG_DATA(nlh);
|
||||
if (nlh->nlmsg_len >= NLMSG_LENGTH(sizeof(*err)) && err->error != 0)
|
||||
errno = -err->error;
|
||||
else
|
||||
errno = EIO;
|
||||
return -1;
|
||||
}
|
||||
if (nlh->nlmsg_type != reply_type)
|
||||
continue;
|
||||
if (cb(nlh, ctx) != 0)
|
||||
return -1;
|
||||
/* A non-multipart reply is the whole answer; nothing follows it. */
|
||||
if ((nlh->nlmsg_flags & NLM_F_MULTI) == 0) {
|
||||
*done = 1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int rd_nl_dump(int fd, unsigned short request_type, unsigned short reply_type,
|
||||
unsigned int seq, rd_nl_cb cb, void *ctx)
|
||||
{
|
||||
struct {
|
||||
struct nlmsghdr nlh;
|
||||
struct rtgenmsg gen;
|
||||
} req;
|
||||
struct sockaddr_nl kernel;
|
||||
char *buf;
|
||||
size_t cap = 8192;
|
||||
int datagrams = 0;
|
||||
int done = 0;
|
||||
int rc = -1;
|
||||
int saved;
|
||||
|
||||
memset(&req, 0, sizeof(req));
|
||||
req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(req.gen));
|
||||
req.nlh.nlmsg_type = request_type;
|
||||
req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP;
|
||||
req.nlh.nlmsg_seq = seq;
|
||||
req.gen.rtgen_family = AF_UNSPEC;
|
||||
|
||||
memset(&kernel, 0, sizeof(kernel));
|
||||
kernel.nl_family = AF_NETLINK;
|
||||
|
||||
for (;;) {
|
||||
if (sendto(fd, &req, req.nlh.nlmsg_len, 0, (struct sockaddr *)&kernel,
|
||||
sizeof(kernel)) >= 0)
|
||||
break;
|
||||
if (errno != EINTR)
|
||||
return -1;
|
||||
}
|
||||
|
||||
buf = (char *)malloc(cap);
|
||||
if (buf == NULL) {
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
|
||||
while (!done) {
|
||||
/* MSG_PEEK|MSG_TRUNC reports the datagram's real size, so an
|
||||
* undersized buffer costs a resize instead of a silent truncation. */
|
||||
ssize_t n = recv(fd, buf, cap, MSG_PEEK | MSG_TRUNC);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR)
|
||||
continue;
|
||||
goto out;
|
||||
}
|
||||
if ((size_t)n > cap) {
|
||||
char *grown;
|
||||
if ((size_t)n > RD_NL_MAX_BUF) {
|
||||
errno = EMSGSIZE;
|
||||
goto out;
|
||||
}
|
||||
grown = (char *)realloc(buf, (size_t)n);
|
||||
if (grown == NULL) {
|
||||
errno = ENOMEM;
|
||||
goto out;
|
||||
}
|
||||
buf = grown;
|
||||
cap = (size_t)n;
|
||||
continue;
|
||||
}
|
||||
n = recv(fd, buf, cap, 0);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR)
|
||||
continue;
|
||||
goto out;
|
||||
}
|
||||
if (n == 0 || ++datagrams > RD_NL_MAX_DATAGRAMS) {
|
||||
errno = EIO;
|
||||
goto out;
|
||||
}
|
||||
if (rd_nl_parse(buf, (int)n, reply_type, seq, cb, ctx, &done) != 0)
|
||||
goto out;
|
||||
}
|
||||
rc = 0;
|
||||
|
||||
out:
|
||||
saved = errno;
|
||||
free(buf);
|
||||
errno = saved;
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int rd_link_cb(struct nlmsghdr *nlh, void *ctx)
|
||||
{
|
||||
struct rd_link_table *t = (struct rd_link_table *)ctx;
|
||||
struct ifinfomsg *ifi;
|
||||
struct rtattr *tb[IFLA_IFNAME + 1];
|
||||
struct rd_link_info *slot;
|
||||
int payload;
|
||||
int namelen;
|
||||
|
||||
if (nlh->nlmsg_len < NLMSG_LENGTH(sizeof(*ifi)))
|
||||
return 0;
|
||||
ifi = (struct ifinfomsg *)NLMSG_DATA(nlh);
|
||||
payload = (int)nlh->nlmsg_len - (int)NLMSG_SPACE(sizeof(*ifi));
|
||||
if (payload < 0)
|
||||
payload = 0;
|
||||
rd_parse_rtattr(IFLA_RTA(ifi), payload, tb, IFLA_IFNAME);
|
||||
|
||||
/* An interface we cannot name is of no use: callers dereference ifa_name. */
|
||||
if (tb[IFLA_IFNAME] == NULL || (int)RTA_PAYLOAD(tb[IFLA_IFNAME]) <= 0)
|
||||
return 0;
|
||||
|
||||
if (t->len == t->cap) {
|
||||
size_t ncap = t->cap ? t->cap * 2 : 16;
|
||||
struct rd_link_info *items =
|
||||
(struct rd_link_info *)realloc(t->items, ncap * sizeof(*items));
|
||||
if (items == NULL) {
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
t->items = items;
|
||||
t->cap = ncap;
|
||||
}
|
||||
|
||||
slot = &t->items[t->len];
|
||||
memset(slot, 0, sizeof(*slot));
|
||||
slot->index = (unsigned int)ifi->ifi_index;
|
||||
slot->flags = ifi->ifi_flags;
|
||||
namelen = (int)RTA_PAYLOAD(tb[IFLA_IFNAME]);
|
||||
if (namelen > IFNAMSIZ)
|
||||
namelen = IFNAMSIZ;
|
||||
memcpy(slot->name, RTA_DATA(tb[IFLA_IFNAME]), (size_t)namelen);
|
||||
slot->name[namelen] = '\0';
|
||||
t->len++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const struct rd_link_info *rd_link_find(const struct rd_link_table *t,
|
||||
unsigned int index)
|
||||
{
|
||||
size_t i;
|
||||
for (i = 0; i < t->len; i++) {
|
||||
if (t->items[i].index == index)
|
||||
return &t->items[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void rd_fill_mask(unsigned char *out, int len, unsigned int prefix)
|
||||
{
|
||||
int i;
|
||||
if (prefix > (unsigned int)len * 8)
|
||||
prefix = (unsigned int)len * 8;
|
||||
for (i = 0; i < len; i++) {
|
||||
if (prefix >= 8) {
|
||||
out[i] = 0xff;
|
||||
prefix -= 8;
|
||||
} else if (prefix > 0) {
|
||||
out[i] = (unsigned char)(0xff << (8 - prefix));
|
||||
prefix = 0;
|
||||
} else {
|
||||
out[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void rd_set_in(struct sockaddr_storage *ss, const void *addr)
|
||||
{
|
||||
struct sockaddr_in *sin = (struct sockaddr_in *)ss;
|
||||
sin->sin_family = AF_INET;
|
||||
memcpy(&sin->sin_addr, addr, 4);
|
||||
}
|
||||
|
||||
static int rd_addr_cb(struct nlmsghdr *nlh, void *ctx)
|
||||
{
|
||||
struct rd_addr_ctx *c = (struct rd_addr_ctx *)ctx;
|
||||
struct ifaddrmsg *ifa;
|
||||
struct rtattr *tb[IFA_BROADCAST + 1];
|
||||
struct rtattr *ra;
|
||||
const struct rd_link_info *link;
|
||||
struct rd_ifaddrs_storage *st;
|
||||
int payload;
|
||||
|
||||
if (nlh->nlmsg_len < NLMSG_LENGTH(sizeof(*ifa)))
|
||||
return 0;
|
||||
ifa = (struct ifaddrmsg *)NLMSG_DATA(nlh);
|
||||
if (ifa->ifa_family != AF_INET && ifa->ifa_family != AF_INET6)
|
||||
return 0;
|
||||
|
||||
/* Without the link entry there is no name, and callers deref ifa_name. */
|
||||
link = rd_link_find(c->links, ifa->ifa_index);
|
||||
if (link == NULL)
|
||||
return 0;
|
||||
|
||||
payload = (int)nlh->nlmsg_len - (int)NLMSG_SPACE(sizeof(*ifa));
|
||||
if (payload < 0)
|
||||
payload = 0;
|
||||
rd_parse_rtattr(IFA_RTA(ifa), payload, tb, IFA_BROADCAST);
|
||||
|
||||
/* On a point-to-point link IFA_ADDRESS holds the peer and IFA_LOCAL the
|
||||
* local address; ipv6 only ever sets IFA_ADDRESS. */
|
||||
if (ifa->ifa_family == AF_INET)
|
||||
ra = tb[IFA_LOCAL] ? tb[IFA_LOCAL] : tb[IFA_ADDRESS];
|
||||
else
|
||||
ra = tb[IFA_ADDRESS] ? tb[IFA_ADDRESS] : tb[IFA_LOCAL];
|
||||
if (ra == NULL)
|
||||
return 0;
|
||||
if ((int)RTA_PAYLOAD(ra) < (ifa->ifa_family == AF_INET ? 4 : 16))
|
||||
return 0;
|
||||
|
||||
st = (struct rd_ifaddrs_storage *)calloc(1, sizeof(*st));
|
||||
if (st == NULL) {
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(st->name, link->name, sizeof(st->name));
|
||||
st->ifa.ifa_name = st->name;
|
||||
st->ifa.ifa_flags = link->flags;
|
||||
st->ifa.ifa_addr = (struct sockaddr *)&st->addr;
|
||||
st->ifa.ifa_netmask = (struct sockaddr *)&st->netmask;
|
||||
|
||||
if (ifa->ifa_family == AF_INET) {
|
||||
struct sockaddr_in *mask = (struct sockaddr_in *)&st->netmask;
|
||||
|
||||
rd_set_in(&st->addr, RTA_DATA(ra));
|
||||
mask->sin_family = AF_INET;
|
||||
rd_fill_mask((unsigned char *)&mask->sin_addr, 4, ifa->ifa_prefixlen);
|
||||
|
||||
if ((link->flags & IFF_POINTOPOINT) && tb[IFA_ADDRESS] && tb[IFA_LOCAL] &&
|
||||
(int)RTA_PAYLOAD(tb[IFA_ADDRESS]) >= 4 &&
|
||||
memcmp(RTA_DATA(tb[IFA_ADDRESS]), RTA_DATA(tb[IFA_LOCAL]), 4) != 0) {
|
||||
rd_set_in(&st->ifu, RTA_DATA(tb[IFA_ADDRESS]));
|
||||
st->ifa.ifa_dstaddr = (struct sockaddr *)&st->ifu;
|
||||
} else if (tb[IFA_BROADCAST] && (int)RTA_PAYLOAD(tb[IFA_BROADCAST]) >= 4) {
|
||||
rd_set_in(&st->ifu, RTA_DATA(tb[IFA_BROADCAST]));
|
||||
st->ifa.ifa_broadaddr = (struct sockaddr *)&st->ifu;
|
||||
}
|
||||
} else {
|
||||
struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *)&st->addr;
|
||||
struct sockaddr_in6 *mask = (struct sockaddr_in6 *)&st->netmask;
|
||||
|
||||
sin6->sin6_family = AF_INET6;
|
||||
memcpy(&sin6->sin6_addr, RTA_DATA(ra), 16);
|
||||
/* A link-local address is not routable without its scope id. */
|
||||
if (IN6_IS_ADDR_LINKLOCAL(&sin6->sin6_addr) ||
|
||||
IN6_IS_ADDR_MC_LINKLOCAL(&sin6->sin6_addr))
|
||||
sin6->sin6_scope_id = ifa->ifa_index;
|
||||
mask->sin6_family = AF_INET6;
|
||||
rd_fill_mask((unsigned char *)&mask->sin6_addr, 16, ifa->ifa_prefixlen);
|
||||
}
|
||||
|
||||
if (c->tail != NULL)
|
||||
c->tail->ifa_next = &st->ifa;
|
||||
else
|
||||
c->head = &st->ifa;
|
||||
c->tail = &st->ifa;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void freeifaddrs(struct ifaddrs *ifa)
|
||||
{
|
||||
while (ifa != NULL) {
|
||||
struct ifaddrs *next = ifa->ifa_next;
|
||||
free(ifa);
|
||||
ifa = next;
|
||||
}
|
||||
}
|
||||
|
||||
int getifaddrs(struct ifaddrs **ifap)
|
||||
{
|
||||
struct rd_link_table links;
|
||||
struct rd_addr_ctx ctx;
|
||||
int fd;
|
||||
int saved;
|
||||
|
||||
if (ifap == NULL) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
*ifap = NULL;
|
||||
|
||||
memset(&links, 0, sizeof(links));
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
ctx.links = &links;
|
||||
|
||||
fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE);
|
||||
if (fd < 0)
|
||||
return -1;
|
||||
|
||||
if (rd_nl_dump(fd, RTM_GETLINK, RTM_NEWLINK, 1, rd_link_cb, &links) != 0)
|
||||
goto fail;
|
||||
if (rd_nl_dump(fd, RTM_GETADDR, RTM_NEWADDR, 2, rd_addr_cb, &ctx) != 0)
|
||||
goto fail;
|
||||
|
||||
close(fd);
|
||||
free(links.items);
|
||||
*ifap = ctx.head;
|
||||
return 0;
|
||||
|
||||
fail:
|
||||
saved = errno;
|
||||
close(fd);
|
||||
free(links.items);
|
||||
freeifaddrs(ctx.head);
|
||||
errno = saved;
|
||||
return -1;
|
||||
}
|
||||
@@ -174,7 +174,12 @@ async fn connect_and_login(
|
||||
received = true;
|
||||
interface.update_received(true);
|
||||
}
|
||||
let msg_in = Message::parse_from_bytes(&bytes)?;
|
||||
let msg_in = match Message::parse_from_bytes(&bytes) {
|
||||
Ok(msg) => msg,
|
||||
Err(err) => {
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
match msg_in.union {
|
||||
Some(message::Union::Hash(hash)) => {
|
||||
if !interface.handle_hash(password, hash, &mut stream).await {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::{
|
||||
collections::{hash_map::RandomState, HashMap, VecDeque},
|
||||
hash::BuildHasher,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -21,8 +23,13 @@ use hbb_common::{
|
||||
rendezvous_proto::*,
|
||||
sleep,
|
||||
socket_client::{self, connect_tcp, is_ipv4, new_direct_udp_for, new_udp_for},
|
||||
tokio::{self, select, sync::Mutex, time::interval},
|
||||
tokio::{
|
||||
self, select,
|
||||
sync::{mpsc, Mutex},
|
||||
time::interval,
|
||||
},
|
||||
udp::FramedSocket,
|
||||
webrtc::WebRTCStream,
|
||||
AddrMangle, IntoTargetAddr, ResultType, Stream, TargetAddr,
|
||||
};
|
||||
|
||||
@@ -47,7 +54,66 @@ lazy_static::lazy_static! {
|
||||
static ref SOLVING_PK_MISMATCH: Mutex<String> = Default::default();
|
||||
static ref LAST_MSG: Mutex<(SocketAddr, Instant)> = Mutex::new((SocketAddr::new([0; 4].into(), 0), Instant::now()));
|
||||
static ref LAST_RELAY_MSG: Mutex<(SocketAddr, Instant)> = Mutex::new((SocketAddr::new([0; 4].into(), 0), Instant::now()));
|
||||
static ref WEBRTC_ICE_TXS: Mutex<HashMap<String, IceRoute>> = Default::default();
|
||||
static ref ICE_DIGEST_STATE: RandomState = Default::default();
|
||||
}
|
||||
/// Remote ICE candidates buffered per session while the answerer applies them. Same depth as the
|
||||
/// controller's own buffer (`Client::MAX_PENDING_WEBRTC_ICE`), though that one evicts its oldest
|
||||
/// where a full channel here refuses the newest.
|
||||
const MAX_PENDING_REMOTE_ICE: usize = 64;
|
||||
/// Queued candidates remembered so the controller's re-send is skipped instead of taking a slot
|
||||
/// of its own. Far more than an honest peer gathers, at eight bytes each.
|
||||
const ICE_DEDUP_WINDOW: usize = 256;
|
||||
// The rendezvous ICE route is reachable without a prior punch and the peer decides how many
|
||||
// candidates it sends, so these sites would let someone else set how much this machine writes to
|
||||
// its log file. One line a minute each, carrying the suppressed count.
|
||||
const ICE_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
static UNKNOWN_ICE_SESSION_LOG: hbb_common::log_throttle::LogThrottle =
|
||||
hbb_common::log_throttle::LogThrottle::new(ICE_LOG_INTERVAL);
|
||||
static REJECTED_REMOTE_ICE_LOG: hbb_common::log_throttle::LogThrottle =
|
||||
hbb_common::log_throttle::LogThrottle::new(ICE_LOG_INTERVAL);
|
||||
static FULL_ICE_QUEUE_LOG: hbb_common::log_throttle::LogThrottle =
|
||||
hbb_common::log_throttle::LogThrottle::new(ICE_LOG_INTERVAL);
|
||||
|
||||
struct IceRoute {
|
||||
tx: mpsc::Sender<String>,
|
||||
recent: VecDeque<u64>,
|
||||
}
|
||||
|
||||
impl IceRoute {
|
||||
fn new(tx: mpsc::Sender<String>) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
recent: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps `queue` the only way onto the channel, so nothing reaches it unrecorded.
|
||||
fn is_same_channel(&self, other: &mpsc::Sender<String>) -> bool {
|
||||
self.tx.same_channel(other)
|
||||
}
|
||||
|
||||
/// Skip the controller's re-send of a candidate already queued: the ICE agent that dedups
|
||||
/// repeats is downstream of this queue, so the copy would spend a slot of its own.
|
||||
/// False means the candidate was dropped.
|
||||
fn queue(&mut self, candidate: String) -> bool {
|
||||
let digest = ICE_DIGEST_STATE.hash_one(candidate.as_str());
|
||||
if self.recent.contains(&digest) {
|
||||
// Only honest about the drop if the route is still alive to have taken it.
|
||||
return !self.tx.is_closed();
|
||||
}
|
||||
// Recorded once queued, never before: a refused candidate stays repairable by the re-send.
|
||||
if self.tx.try_send(candidate).is_err() {
|
||||
return false;
|
||||
}
|
||||
if self.recent.len() >= ICE_DEDUP_WINDOW {
|
||||
self.recent.pop_front();
|
||||
}
|
||||
self.recent.push_back(digest);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
static SHOULD_EXIT: AtomicBool = AtomicBool::new(false);
|
||||
static MANUAL_RESTARTED: AtomicBool = AtomicBool::new(false);
|
||||
static SENT_REGISTER_PK: AtomicBool = AtomicBool::new(false);
|
||||
@@ -399,6 +465,30 @@ impl RendezvousMediator {
|
||||
allow_err!(rz.handle_intranet(fla, server).await);
|
||||
});
|
||||
}
|
||||
Some(rendezvous_message::Union::IceCandidate(ice)) => {
|
||||
let queued = {
|
||||
let mut txs = WEBRTC_ICE_TXS.lock().await;
|
||||
txs.get_mut(&ice.session_key)
|
||||
.map(|route| route.queue(ice.candidate))
|
||||
};
|
||||
match queued {
|
||||
Some(false) => {
|
||||
if let Some(n) = FULL_ICE_QUEUE_LOG.due() {
|
||||
log::debug!("dropped {} ICE candidate(s): queue full or closed", n);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(n) = UNKNOWN_ICE_SESSION_LOG.due() {
|
||||
log::debug!(
|
||||
"dropped {} ICE candidate(s) for unknown WebRTC session key, last: {}",
|
||||
n,
|
||||
ice.session_key
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(rendezvous_message::Union::ConfigureUpdate(cu)) => {
|
||||
let v0 = Config::get_rendezvous_servers();
|
||||
Config::set_option(
|
||||
@@ -508,6 +598,7 @@ impl RendezvousMediator {
|
||||
rr.secure,
|
||||
false,
|
||||
Default::default(),
|
||||
String::new(),
|
||||
meta,
|
||||
)
|
||||
.await
|
||||
@@ -522,6 +613,7 @@ impl RendezvousMediator {
|
||||
secure: bool,
|
||||
initiate: bool,
|
||||
socket_addr_v6: bytes::Bytes,
|
||||
webrtc_sdp_answer: String,
|
||||
meta: ConnectionMeta,
|
||||
) -> ResultType<()> {
|
||||
let peer_addr = AddrMangle::decode(&socket_addr);
|
||||
@@ -540,6 +632,7 @@ impl RendezvousMediator {
|
||||
socket_addr: socket_addr.into(),
|
||||
version: crate::VERSION.to_owned(),
|
||||
socket_addr_v6,
|
||||
webrtc_sdp_answer,
|
||||
..Default::default()
|
||||
};
|
||||
if initiate {
|
||||
@@ -606,6 +699,7 @@ impl RendezvousMediator {
|
||||
true,
|
||||
true,
|
||||
socket_addr_v6,
|
||||
String::new(),
|
||||
meta,
|
||||
)
|
||||
.await
|
||||
@@ -642,6 +736,163 @@ impl RendezvousMediator {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the WebRTC answerer for a punch-hole offer and return the SDP answer that rides in
|
||||
/// the punch reply (PunchHoleSent / RelayResponse).
|
||||
///
|
||||
/// Awaited inline on the punch-reply path, which only holds because everything here is local
|
||||
/// (pc + keygen + SDP; trickle means the answer carries no candidates). Keep network I/O out
|
||||
/// — connection setup belongs in the detached task below.
|
||||
async fn spawn_webrtc_answerer(
|
||||
&self,
|
||||
ph: &PunchHole,
|
||||
relay_only_ice: bool,
|
||||
server: ServerPtr,
|
||||
peer_addr: SocketAddr,
|
||||
meta: ConnectionMeta,
|
||||
) -> ResultType<String> {
|
||||
let mut stream =
|
||||
WebRTCStream::new(&ph.webrtc_sdp_offer, relay_only_ice, CONNECT_TIMEOUT).await?;
|
||||
let answer = stream.local_endpoint().to_owned();
|
||||
let session_key = stream.session_key().to_owned();
|
||||
let return_route = ph.socket_addr.clone();
|
||||
|
||||
// A duplicate PunchHole (the offerer re-sends the same request across punch attempts)
|
||||
// resolves to the SESSIONS-cached stream. `take_local_ice_rx` yields the receiver
|
||||
// exactly once per stream instance, so `None` here means an answerer was already
|
||||
// spawned for this offer: return the (identical) cached answer without spawning a
|
||||
// second connect task. Otherwise two `create_tcp_connection` tasks would detach and
|
||||
// read the same data channel, interleaving the handshake and corrupting the session.
|
||||
let Some(mut local_ice_rx) = stream.take_local_ice_rx() else {
|
||||
return Ok(answer);
|
||||
};
|
||||
|
||||
// Bounded: how many candidates arrive is the sender's choice, while draining one costs a
|
||||
// JSON parse and the ICE agent's lock, so an unbounded queue lets whoever can reach this
|
||||
// session's route grow it without limit inside a long-lived service process. A full queue
|
||||
// drops the newest candidate, and the controller re-sends it once — the digests beside the
|
||||
// sender are what keep that re-send from spending a slot of its own.
|
||||
let (remote_ice_tx, mut remote_ice_rx) = mpsc::channel::<String>(MAX_PENDING_REMOTE_ICE);
|
||||
let own_ice_tx = remote_ice_tx.clone();
|
||||
WEBRTC_ICE_TXS
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_key.clone(), IceRoute::new(remote_ice_tx));
|
||||
|
||||
let stream_for_remote_ice = stream.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(candidate) = remote_ice_rx.recv().await {
|
||||
if let Err(err) = stream_for_remote_ice.add_remote_ice_candidate(&candidate).await
|
||||
{
|
||||
if let Some(n) = REJECTED_REMOTE_ICE_LOG.due() {
|
||||
log::warn!(
|
||||
"failed to add {} remote WebRTC ICE candidate(s), last: {}",
|
||||
n,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
let host = self.host.clone();
|
||||
let socket_addr = return_route.clone();
|
||||
let session_key_for_ice = session_key.clone();
|
||||
tokio::spawn(async move {
|
||||
// Candidates ride a dedicated TCP connection to the rendezvous server, like
|
||||
// the answer, NOT the mediator channel: that channel is UDP in the default
|
||||
// setup, and target deployments front hbbs with websocket/TCP only, where
|
||||
// its UDP port is unreachable. The server keeps candidate-carrying TCP
|
||||
// connections open, so one lazily-opened connection serves the whole
|
||||
// trickle, and TCP reliability replaces the old 400ms duplicate re-send
|
||||
// (the controller keeps its own re-send for the server->peer UDP downlink).
|
||||
let mut conn = None;
|
||||
while let Some(candidate) = local_ice_rx.recv().await {
|
||||
let mut msg = Message::new();
|
||||
msg.set_ice_candidate(IceCandidate {
|
||||
socket_addr: socket_addr.clone(),
|
||||
session_key: session_key_for_ice.clone(),
|
||||
candidate,
|
||||
..Default::default()
|
||||
});
|
||||
// One reconnect attempt per candidate: the first send after an hbbs
|
||||
// restart or an idle-killed connection fails on the stale stream.
|
||||
for _ in 0..2 {
|
||||
if conn.is_none() {
|
||||
match connect_tcp(&*host, CONNECT_TIMEOUT).await {
|
||||
Ok(s) => conn = Some(s),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"failed to connect for WebRTC ICE candidate: {}",
|
||||
err
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = conn.as_mut() {
|
||||
match s.send(&msg).await {
|
||||
Ok(()) => break,
|
||||
Err(err) => {
|
||||
log::debug!(
|
||||
"WebRTC ICE candidate send failed, reconnecting: {}",
|
||||
err
|
||||
);
|
||||
conn = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let session_key_for_cleanup = session_key.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = stream.wait_connected(CONNECT_TIMEOUT).await;
|
||||
// Only evict our own route. The key is the offer's DTLS fingerprint, identical across
|
||||
// the controller's punch retries, so a retry that built a fresh answerer has already
|
||||
// replaced this entry — removing it blindly would delete the live session's sender and
|
||||
// leave it receiving no candidates at all.
|
||||
{
|
||||
let mut txs = WEBRTC_ICE_TXS.lock().await;
|
||||
if txs
|
||||
.get(&session_key_for_cleanup)
|
||||
.is_some_and(|route| route.is_same_channel(&own_ice_tx))
|
||||
{
|
||||
txs.remove(&session_key_for_cleanup);
|
||||
}
|
||||
}
|
||||
if let Err(err) = result {
|
||||
log::warn!("webrtc wait_connected failed: {}", err);
|
||||
// Release the pc now rather than waiting for the ICE agent to time out into a
|
||||
// terminal state (~30s); this also drops the SESSIONS entry promptly.
|
||||
stream.close().await;
|
||||
return;
|
||||
}
|
||||
// create_tcp_connection takes ownership of the stream; keep a handle to close the pc
|
||||
// once the session returns. It runs the whole session and returns Ok on normal end,
|
||||
// Err on setup failure — either way the pc must be closed, else it lingers forever in
|
||||
// SESSIONS (its state handler only fires on a terminal ICE state, which a cleanly
|
||||
// closed session may never reach) leaking the pc, channels, and socket fds.
|
||||
let stream_for_cleanup = stream.clone();
|
||||
if let Err(err) = crate::server::create_tcp_connection(
|
||||
server,
|
||||
Stream::WebRTC(stream),
|
||||
peer_addr,
|
||||
true,
|
||||
meta,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("failed to create WebRTC server connection: {}", err);
|
||||
}
|
||||
stream_for_cleanup.close().await;
|
||||
});
|
||||
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
async fn handle_punch_hole(&self, ph: PunchHole, server: ServerPtr) -> ResultType<()> {
|
||||
let mut peer_addr = AddrMangle::decode(&ph.socket_addr);
|
||||
let last = *LAST_MSG.lock().await;
|
||||
@@ -651,12 +902,41 @@ impl RendezvousMediator {
|
||||
return Ok(());
|
||||
}
|
||||
let peer_addr_v6 = hbb_common::AddrMangle::decode(&ph.socket_addr_v6);
|
||||
let relay = use_ws() || Config::is_proxy() || ph.force_relay;
|
||||
let local_proxy = use_ws() || Config::is_proxy();
|
||||
let relay = local_proxy || ph.force_relay;
|
||||
let mut socket_addr_v6 = Default::default();
|
||||
let meta = connection_meta(
|
||||
ph.control_permissions.into_option(),
|
||||
ph.controlled_context.into_option(),
|
||||
ph.control_permissions.clone().into_option(),
|
||||
ph.controlled_context.clone().into_option(),
|
||||
);
|
||||
// The controller's force_relay alone does not say whether ICE must be Relay-only; its
|
||||
// offer envelope does. `ice_policy: "all"` means the relay was forced by the transport
|
||||
// (ws), so answer with full ICE and let a direct pair form.
|
||||
let webrtc_relay_only =
|
||||
ph.force_relay && !WebRTCStream::endpoint_declares_all_ice(&ph.webrtc_sdp_offer);
|
||||
// No enable-webrtc check here: it is LocalConfig, which the UI process writes and never
|
||||
// syncs over IPC, so this (server) process would read the private-server default of "N"
|
||||
// and refuse to answer in exactly the self-hosted deployments the transport is for.
|
||||
// A proxy still rules it out — ICE would bypass it and leak the real IP.
|
||||
let webrtc_viable = !ph.webrtc_sdp_offer.is_empty()
|
||||
&& !Config::is_proxy()
|
||||
&& (!webrtc_relay_only || WebRTCStream::has_turn_server());
|
||||
let webrtc_sdp_answer = if webrtc_viable {
|
||||
self.spawn_webrtc_answerer(
|
||||
&ph,
|
||||
webrtc_relay_only,
|
||||
server.clone(),
|
||||
peer_addr,
|
||||
meta.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
log::warn!("failed to create WebRTC answer: {}", err);
|
||||
String::new()
|
||||
})
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if peer_addr_v6.port() > 0 && !relay {
|
||||
socket_addr_v6 =
|
||||
start_ipv6(peer_addr_v6, peer_addr, server.clone(), meta.clone()).await;
|
||||
@@ -678,6 +958,7 @@ impl RendezvousMediator {
|
||||
true,
|
||||
true,
|
||||
socket_addr_v6.clone(),
|
||||
webrtc_sdp_answer.clone(),
|
||||
meta,
|
||||
)
|
||||
.await;
|
||||
@@ -691,6 +972,7 @@ impl RendezvousMediator {
|
||||
nat_type: nat_type.into(),
|
||||
version: crate::VERSION.to_owned(),
|
||||
socket_addr_v6,
|
||||
webrtc_sdp_answer,
|
||||
..Default::default()
|
||||
};
|
||||
if ph.udp_port > 0 {
|
||||
@@ -699,12 +981,25 @@ impl RendezvousMediator {
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
if !ph.webrtc_sdp_offer.is_empty() {
|
||||
// Return the answer over its own short-lived TCP connection rather than the mediator
|
||||
// channel: that channel is UDP by default, and hbbs applies UDP-punch semantics
|
||||
// (source-address observation) to a PunchHoleSent that arrives on it. No TCP punch
|
||||
// is made — the controller keeps its request socket for trickled ICE.
|
||||
let mut msg_out = Message::new();
|
||||
msg_out.set_punch_hole_sent(msg_punch);
|
||||
let mut socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
|
||||
socket.send(&msg_out).await?;
|
||||
return Ok(());
|
||||
}
|
||||
log::debug!("Punch tcp hole to {:?}", peer_addr);
|
||||
let mut socket = {
|
||||
let socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
|
||||
let local_addr = socket.local_addr();
|
||||
// key important here for punch hole to tell my gateway incoming peer is safe.
|
||||
// it can not be async here, because local_addr can not be reused, we must close the connection before use it again.
|
||||
// Awaited rather than spawned so the mapping exists before `PunchHoleSent` goes out;
|
||||
// `local_addr` itself is shared, not exclusive - every socket here binds it with the
|
||||
// reuse flags `new_socket` sets.
|
||||
allow_err!(socket_client::connect_tcp_local(peer_addr, Some(local_addr), 30).await);
|
||||
socket
|
||||
};
|
||||
@@ -712,7 +1007,10 @@ impl RendezvousMediator {
|
||||
msg_out.set_punch_hole_sent(msg_punch);
|
||||
let bytes = msg_out.write_to_bytes()?;
|
||||
socket.send_raw(bytes).await?;
|
||||
crate::accept_connection(server.clone(), socket, peer_addr, true, meta).await;
|
||||
let local_addr = socket.local_addr();
|
||||
// The listener inside takes this address over, so the mediator's socket goes first.
|
||||
drop(socket);
|
||||
punch_tcp_until_connected(server, peer_addr, local_addr, meta).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -951,11 +1249,11 @@ async fn udp_nat_listen(
|
||||
let socket_cloned = socket.clone();
|
||||
let func = async {
|
||||
socket.connect(peer_addr).await?;
|
||||
let res = crate::punch_udp(socket.clone(), true).await?;
|
||||
let init_packet = crate::punch_udp(socket.clone(), true).await?;
|
||||
let stream = crate::kcp_stream::KcpStream::accept(
|
||||
socket,
|
||||
Duration::from_millis(CONNECT_TIMEOUT as _),
|
||||
res,
|
||||
init_packet,
|
||||
)
|
||||
.await?;
|
||||
crate::server::create_tcp_connection(server, stream.1, peer_addr_v4, true, meta).await?;
|
||||
@@ -971,6 +1269,194 @@ async fn udp_nat_listen(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where the repeats start, and the factor they slow by. The controller's SYN arrives once, at an
|
||||
/// instant we are never told, inside a window we are not told either: `Client::connect` sizes its
|
||||
/// dial only after our PunchHoleSent, from its own rendezvous time and the direct failures it has
|
||||
/// recorded for us - `CONNECT_TIMEOUT` between two known-asymmetric NATs that never failed, as
|
||||
/// little as a second once one has. So the repeats cover our own ceiling instead, `CONNECT_TIMEOUT`,
|
||||
/// which is as long as the accept below has always been willing to take a connection, and back
|
||||
/// off across it: dense at the start, where every window begins and the short ones end, sparse
|
||||
/// afterwards, which is `punch_udp`'s shape for the same reason.
|
||||
const PUNCH_INTERVAL: f32 = 0.15;
|
||||
const PUNCH_BACKOFF: f32 = 1.5;
|
||||
const PUNCH_MAX_INTERVAL: f32 = 2.0;
|
||||
/// How long a punch in flight may run past the deadline, and the only timer it runs on. A punch
|
||||
/// is cancel-safe while it is still in SYN_SENT and not once the controller's SYN has crossed it:
|
||||
/// the socket is then half way through a handshake, and dropping it there cuts the connection the
|
||||
/// controller is opening - which its `connect` has already returned, so that attempt fails
|
||||
/// outright rather than falling back to relay. A timer cannot tell the two states apart, so no
|
||||
/// punch is cut on a schedule of its own, and none needs to be. A gateway that answers with RST
|
||||
/// fails the connect at once, and the loop punches again. One that drops the SYN in silence
|
||||
/// leaves the socket in SYN_SENT, where it holds the mapping open and the kernel re-sends the
|
||||
/// SYN, and any SYN of the controller's that arrives crosses it - a second punch has nothing to
|
||||
/// add. That leaves the deadline, and this much past it lets a crossing begun just before it
|
||||
/// complete; Windows gives a SYN up at about 21s anyway.
|
||||
const PUNCH_GRACE: u64 = 3000;
|
||||
|
||||
/// The punch above leaves before hbbs has told the controller where to dial, so it is never in
|
||||
/// flight at the same time as the controller's SYN: it opens our NAT, meets nothing, and a gateway
|
||||
/// that answers it with RST takes the mapping down with it - leaving the listener below waiting on
|
||||
/// a hole that no longer exists. Punching again across the window in which the controller dials
|
||||
/// rebuilds it, and once the controller sits in SYN_SENT one of those punches meets its SYN and
|
||||
/// completes as a simultaneous open: a second way in, which a single punch never had.
|
||||
async fn punch_tcp_until_connected(
|
||||
server: ServerPtr,
|
||||
peer_addr: SocketAddr,
|
||||
local_addr: SocketAddr,
|
||||
meta: ConnectionMeta,
|
||||
) {
|
||||
use hbb_common::tcp::new_listener;
|
||||
// Shadows the module's `std::time::Instant`: the deadline is held against tokio's sleeps and
|
||||
// timeouts, so it runs on their clock.
|
||||
use hbb_common::tokio::time::Instant;
|
||||
|
||||
// Not fatal on its own - the punch below can still meet the controller's SYN without it, and
|
||||
// that half is the one a listener the OS refused to bind could not have covered anyway.
|
||||
let listener = match new_listener(local_addr, true).await {
|
||||
Ok(listener) => {
|
||||
log::info!("Server listening on: {local_addr}");
|
||||
Some(listener)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to listen on {local_addr} after punching: {err}");
|
||||
None
|
||||
}
|
||||
};
|
||||
// Bounds both halves: the punch keeps the mapping open only while the accept is still
|
||||
// willing to take a connection through it.
|
||||
let until = Instant::now() + Duration::from_millis(CONNECT_TIMEOUT);
|
||||
let punch = punch_until(until, peer_addr, |ms| {
|
||||
socket_client::connect_tcp_local(peer_addr, Some(local_addr), ms)
|
||||
});
|
||||
let Some(listener) = listener else {
|
||||
if let Some(stream) = punch.await {
|
||||
serve_punched(server, stream, peer_addr, meta).await;
|
||||
}
|
||||
return;
|
||||
};
|
||||
// Accepting in a loop, not once: a transient `accept` error must not spend the whole window
|
||||
// the controller still has to arrive in.
|
||||
let accept = async {
|
||||
loop {
|
||||
let left = until.saturating_duration_since(Instant::now()).as_millis() as u64;
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
match hbb_common::timeout(left, listener.accept()).await {
|
||||
// Not filtered by address, as `accept_connection` never did: hbbs saw the
|
||||
// controller through one mapping and a NAT that pools its external addresses may
|
||||
// dial us from another, and what keeps `meta`'s control permissions from a second
|
||||
// peer is the handshake, plus that exactly one connection is ever served.
|
||||
Ok(Ok(accepted)) => return Some(accepted),
|
||||
Ok(Err(err)) => {
|
||||
log::warn!("Failed to accept from {peer_addr}: {err}");
|
||||
// One that persists - EMFILE, say - would otherwise spin here for the window.
|
||||
sleep(1.).await;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
log::info!("Nothing connected to the hole punched to {peer_addr}");
|
||||
None
|
||||
};
|
||||
// Only the accept races the punch. Racing `accept_connection` instead would race the whole
|
||||
// session it goes on to run, so a punch landing mid-session would tear that session down.
|
||||
//
|
||||
// Whichever arrives first is the one connection this request produces. Serving the loser too
|
||||
// would give a second peer the control permissions hbbs granted for this one controller, and
|
||||
// no test on the connection itself can tell the two apart before `create_tcp_connection` has
|
||||
// spoken to it - so the invariant is kept here, by there being no second serve.
|
||||
let punched = select! {
|
||||
// Both ready at once is two connections, not one seen twice - a crossing carries the
|
||||
// punch's four-tuple, which the listener never matches - and the punch is the one kept:
|
||||
// it is known to have met something at the address hbbs gave, where the accept takes
|
||||
// any address, and dropping it would reset the connection the controller is opening.
|
||||
biased;
|
||||
Some(stream) = punch => stream,
|
||||
Some((stream, addr)) = accept => {
|
||||
return accept_punched_connection(server, stream, addr, meta).await;
|
||||
}
|
||||
else => return,
|
||||
};
|
||||
serve_punched(server, punched, peer_addr, meta).await;
|
||||
}
|
||||
|
||||
/// The repeats of `punch_tcp_until_connected`, over any punch rather than `connect_tcp_local`
|
||||
/// alone, so that a test can run the schedule against a paused clock - which no socket can be.
|
||||
async fn punch_until<T, F, Fut>(
|
||||
until: tokio::time::Instant,
|
||||
peer_addr: SocketAddr,
|
||||
mut punch: F,
|
||||
) -> Option<T>
|
||||
where
|
||||
F: FnMut(u64) -> Fut,
|
||||
Fut: std::future::Future<Output = ResultType<T>>,
|
||||
{
|
||||
use hbb_common::tokio::time::Instant;
|
||||
|
||||
let mut interval = PUNCH_INTERVAL;
|
||||
let mut round = 0;
|
||||
loop {
|
||||
// The deadline decides whether another punch starts, never how long one already in
|
||||
// flight may take: that one runs to PUNCH_GRACE past it.
|
||||
let left = until.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
log::debug!("None of {round} punches to {peer_addr} was met");
|
||||
return None;
|
||||
}
|
||||
// Cut at the deadline rather than slept out past it, so the window ends on a punch and
|
||||
// not on a gap of up to PUNCH_MAX_INTERVAL: the controller's window opened after ours,
|
||||
// on the PunchHoleSent hbbs relayed, so one as long as ours is still open through our tail.
|
||||
tokio::time::sleep(Duration::from_secs_f32(interval).min(left)).await;
|
||||
interval = (interval * PUNCH_BACKOFF).min(PUNCH_MAX_INTERVAL);
|
||||
let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64 + PUNCH_GRACE;
|
||||
match punch(ms).await {
|
||||
// The controller's SYN crossed this punch, so the stream is the connection it
|
||||
// dialed, not a spare one: dropping it would reset that connection.
|
||||
Ok(stream) => return Some(stream),
|
||||
// Not logged one by one, but the count says which gateway it was: RST fails a
|
||||
// punch at once and fits a dozen into the window, a silent drop holds the one
|
||||
// punch for the whole of it. `connect_tcp_local` keeps no errno anyway.
|
||||
Err(_) => round += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_punched(
|
||||
server: ServerPtr,
|
||||
stream: Stream,
|
||||
peer_addr: SocketAddr,
|
||||
meta: ConnectionMeta,
|
||||
) {
|
||||
log::info!("Punched tcp hole to {peer_addr}, connected on the punch itself");
|
||||
if let Err(err) =
|
||||
crate::server::create_tcp_connection(server, stream, peer_addr, true, meta).await
|
||||
{
|
||||
log::warn!("Failed to serve the connection punched to {peer_addr}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The accept half of `accept_connection`, kept here because only the accept may race the punch.
|
||||
async fn accept_punched_connection(
|
||||
server: ServerPtr,
|
||||
stream: tokio::net::TcpStream,
|
||||
addr: SocketAddr,
|
||||
meta: ConnectionMeta,
|
||||
) {
|
||||
use crate::server::create_tcp_connection;
|
||||
|
||||
stream.set_nodelay(true).ok();
|
||||
match stream.local_addr() {
|
||||
Ok(stream_addr) => {
|
||||
let stream = Stream::from(stream, stream_addr);
|
||||
if let Err(err) = create_tcp_connection(server, stream, addr, true, meta).await {
|
||||
log::warn!("Failed to serve the connection from {addr}: {err}");
|
||||
}
|
||||
}
|
||||
Err(err) => log::warn!("Failed to read the address accepted from {addr}: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
// When config is not yet synced from root, register_pk may have already been sent with a new generated pk.
|
||||
// After config sync completes, the pk may change. This struct detects pk changes and triggers
|
||||
// a re-registration by setting key_confirmed to false.
|
||||
@@ -995,3 +1481,255 @@ impl Drop for CheckIfResendPk {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{mpsc, socket_client, tokio, IceRoute, ICE_DEDUP_WINDOW, MAX_PENDING_REMOTE_ICE};
|
||||
use hbb_common::tcp::new_listener;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
// A SOCKS proxy makes `connect_tcp_local` dial the proxy and ignore the local address, so
|
||||
// nothing these two assert can hold. Read once, from the same global config production reads.
|
||||
fn proxied() -> bool {
|
||||
hbb_common::config::Config::get_socks().is_some()
|
||||
}
|
||||
|
||||
/// Both held while their addresses are read, so the pair cannot be the same port - which
|
||||
/// `SO_REUSEPORT` would let bind twice rather than refuse, leaving the tests degenerate.
|
||||
async fn free_loopback_pair() -> (SocketAddr, SocketAddr) {
|
||||
let (a, b) = (
|
||||
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(),
|
||||
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(),
|
||||
);
|
||||
(a.local_addr().unwrap(), b.local_addr().unwrap())
|
||||
}
|
||||
|
||||
fn queue(route: &mut IceRoute, candidate: &str) -> bool {
|
||||
route.queue(candidate.to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_re_sent_copy_does_not_spend_a_queue_slot() {
|
||||
// Two slots, three sends: without the dedup the re-send takes the second and "relay",
|
||||
// the one that traverses NAT, is the one refused.
|
||||
let (tx, mut rx) = mpsc::channel::<String>(2);
|
||||
let mut route = IceRoute::new(tx);
|
||||
for _ in 0..2 {
|
||||
assert!(queue(&mut route, "host"));
|
||||
}
|
||||
assert!(queue(&mut route, "relay"));
|
||||
let mut queued = Vec::new();
|
||||
while let Ok(candidate) = rx.try_recv() {
|
||||
queued.push(candidate);
|
||||
}
|
||||
assert_eq!(queued, vec!["host".to_owned(), "relay".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_candidate_the_full_queue_refused_is_not_remembered() {
|
||||
let (tx, mut rx) = mpsc::channel::<String>(1);
|
||||
let mut route = IceRoute::new(tx);
|
||||
assert!(queue(&mut route, "host"));
|
||||
assert!(!queue(&mut route, "relay"));
|
||||
// The re-send is the only repair for a refused candidate; remembering it would swallow it.
|
||||
assert_eq!(rx.try_recv().ok(), Some("host".to_owned()));
|
||||
assert!(queue(&mut route, "relay"));
|
||||
assert_eq!(rx.try_recv().ok(), Some("relay".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_re_send_is_skipped_while_the_original_is_still_queued() {
|
||||
let (tx, mut rx) = mpsc::channel::<String>(MAX_PENDING_REMOTE_ICE);
|
||||
let mut route = IceRoute::new(tx);
|
||||
for i in 0..MAX_PENDING_REMOTE_ICE {
|
||||
assert!(queue(&mut route, &format!("candidate-{}", i)));
|
||||
}
|
||||
assert!(queue(&mut route, "candidate-0"));
|
||||
let mut queued = 0;
|
||||
while rx.try_recv().is_ok() {
|
||||
queued += 1;
|
||||
}
|
||||
assert_eq!(queued, MAX_PENDING_REMOTE_ICE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_forgets_in_arrival_order() {
|
||||
let (tx, mut rx) = mpsc::channel::<String>(MAX_PENDING_REMOTE_ICE);
|
||||
let mut route = IceRoute::new(tx);
|
||||
for i in 0..=ICE_DEDUP_WINDOW {
|
||||
assert!(queue(&mut route, &format!("candidate-{}", i)));
|
||||
assert!(rx.try_recv().is_ok());
|
||||
}
|
||||
// The oldest digest made room for the newest, so its re-send is admitted again.
|
||||
assert!(queue(&mut route, "candidate-0"));
|
||||
assert!(rx.try_recv().is_ok());
|
||||
// A recent one is still skipped.
|
||||
let recent = format!("candidate-{}", ICE_DEDUP_WINDOW);
|
||||
assert!(queue(&mut route, &recent));
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
// The second way in that the repeat punch opens: a punch reaching a peer already in SYN_SENT
|
||||
// is answered by that socket rather than reset, and the two ends come up on one connection.
|
||||
// A punch that misses the crossing is reset outright here, loopback having no NAT to absorb
|
||||
// it and no round trip to hide behind - so a single punch lands only by luck, and repeating
|
||||
// is what makes it land at all. That is the premise of the repeat, asserted directly. A round
|
||||
// that misses costs one loopback RST, so rounds are cheap and there are many.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_punch_that_meets_the_peers_syn_connects_both_ends() {
|
||||
// The crossing needs both connects genuinely in flight at once. Loopback answers a SYN to
|
||||
// a port nobody is listening on with an instant RST, so on one CPU the first connect runs
|
||||
// to completion before the second is scheduled and no round can ever cross - a property of
|
||||
// the box, which this test cannot tell apart from a broken punch.
|
||||
if proxied() || std::thread::available_parallelism().map_or(true, |cpus| cpus.get() < 2) {
|
||||
return;
|
||||
}
|
||||
for _ in 0..256 {
|
||||
let (a, b) = free_loopback_pair().await;
|
||||
// Held for the whole crossing, because production always has one here and the design
|
||||
// rests on which of the two the kernel hands the connection to: the punch and the
|
||||
// peer's SYN share a four-tuple exactly, the listener only matches the address, and
|
||||
// the punch has to win that or every crossing would be swallowed as a plain accept.
|
||||
let listener = new_listener(a, true).await.unwrap();
|
||||
let to_b = tokio::spawn(socket_client::connect_tcp_local(b, Some(a), 3000));
|
||||
let to_a = tokio::spawn(socket_client::connect_tcp_local(a, Some(b), 3000));
|
||||
let (at_a, at_b) = tokio::join!(to_b, to_a);
|
||||
let (Ok(Ok(mut at_a)), Ok(Ok(mut at_b))) = (at_a, at_b) else {
|
||||
continue;
|
||||
};
|
||||
at_a.send_bytes(bytes::Bytes::from_static(b"punch"))
|
||||
.await
|
||||
.unwrap();
|
||||
let got = at_b.next_timeout(3000).await.unwrap().unwrap();
|
||||
assert_eq!(&got[..], b"punch", "both ends must share one connection");
|
||||
assert!(
|
||||
hbb_common::timeout(200, listener.accept()).await.is_err(),
|
||||
"the crossing must reach the punch, not be accepted as an inbound connection"
|
||||
);
|
||||
return;
|
||||
}
|
||||
panic!("no punch met the peer's SYN in 256 rounds on a machine that can cross them");
|
||||
}
|
||||
|
||||
// The punch binds the address the listener already holds, so it has to go through the same
|
||||
// `connect_tcp_local` production uses - a punch built by hand here would still pass if
|
||||
// `new_socket` ever stopped setting the reuse flags, while every real punch failed to bind.
|
||||
// The peer's view of the source port is what proves the bind took: a fallback to an ephemeral
|
||||
// one would connect just as happily.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_punch_binds_the_address_the_listener_holds() {
|
||||
if proxied() {
|
||||
return;
|
||||
}
|
||||
// `free_loopback_pair` hands back ports it no longer holds, so another process can take
|
||||
// one in between; retry rather than fail for something the punch had no part in.
|
||||
for _ in 0..8 {
|
||||
let (local, peer_addr) = free_loopback_pair().await;
|
||||
let (Ok(listener), Ok(peer)) = (
|
||||
new_listener(local, true).await,
|
||||
new_listener(peer_addr, true).await,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let punch = tokio::spawn(socket_client::connect_tcp_local(
|
||||
peer_addr,
|
||||
Some(local),
|
||||
1500,
|
||||
));
|
||||
let (_peer_side, seen_as) = hbb_common::timeout(3000, peer.accept())
|
||||
.await
|
||||
.expect("the punch must reach the peer")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
seen_as.port(),
|
||||
local.port(),
|
||||
"the punch must leave from the address the listener holds, not an ephemeral one"
|
||||
);
|
||||
// Held, not asserted and dropped: the coexistence below is only exercised while this
|
||||
// socket is still on the address, which is the state production spends its window in.
|
||||
let _punched = punch.await.unwrap().expect("the punch must connect");
|
||||
|
||||
let dialed = tokio::spawn(tokio::net::TcpStream::connect(local));
|
||||
let accepted = hbb_common::timeout(3000, listener.accept()).await;
|
||||
assert!(
|
||||
matches!(accepted, Ok(Ok(_))),
|
||||
"the listener must still take connections while a punch shares its address: {accepted:?}"
|
||||
);
|
||||
assert!(dialed.await.unwrap().is_ok());
|
||||
return;
|
||||
}
|
||||
panic!("could not hold two free loopback addresses in 8 tries");
|
||||
}
|
||||
|
||||
// The schedule on its own, against a paused clock: the window is CONNECT_TIMEOUT long, and
|
||||
// what these pin is where inside it the punches fall, which no socket could show.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn the_punches_end_on_one_at_the_deadline() {
|
||||
use super::{punch_until, PUNCH_GRACE, PUNCH_INTERVAL, PUNCH_MAX_INTERVAL};
|
||||
use hbb_common::{anyhow::anyhow, config::CONNECT_TIMEOUT};
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
|
||||
let peer: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||
let start = Instant::now();
|
||||
let until = start + Duration::from_millis(CONNECT_TIMEOUT);
|
||||
let mut punches = Vec::new();
|
||||
// A gateway that answers with RST: every punch fails the moment it is made.
|
||||
let met = punch_until::<(), _, _>(until, peer, |ms| {
|
||||
punches.push((Instant::now(), ms));
|
||||
async { Err(anyhow!("RST")) }
|
||||
})
|
||||
.await;
|
||||
assert!(met.is_none());
|
||||
assert_eq!(
|
||||
Instant::now(),
|
||||
until,
|
||||
"must return the moment the window closes, not a backoff later"
|
||||
);
|
||||
// Tokio rounds every sleep up to the next millisecond.
|
||||
let slack = Duration::from_millis(1);
|
||||
assert!(punches[0].0 - start <= Duration::from_secs_f32(PUNCH_INTERVAL) + slack);
|
||||
for pair in punches.windows(2) {
|
||||
assert!(
|
||||
pair[1].0 - pair[0].0 <= Duration::from_secs_f32(PUNCH_MAX_INTERVAL) + slack,
|
||||
"no gap in the window may exceed the backoff ceiling: {pair:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
*punches.last().unwrap(),
|
||||
(until, PUNCH_GRACE),
|
||||
"the window must end on a punch, given the whole grace"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_punch_in_flight_runs_the_grace_past_the_deadline_and_no_further() {
|
||||
use super::{punch_until, PUNCH_GRACE};
|
||||
use hbb_common::{anyhow::anyhow, config::CONNECT_TIMEOUT};
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
|
||||
let peer: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||
let until = Instant::now() + Duration::from_millis(CONNECT_TIMEOUT);
|
||||
let mut punches = 0;
|
||||
// A gateway that drops the SYN in silence: the punch sits in SYN_SENT for all it is given.
|
||||
let met = punch_until::<(), _, _>(until, peer, |ms| {
|
||||
punches += 1;
|
||||
async move {
|
||||
tokio::time::sleep(Duration::from_millis(ms)).await;
|
||||
Err(anyhow!("timed out"))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(met.is_none());
|
||||
assert_eq!(
|
||||
punches, 1,
|
||||
"a punch held in SYN_SENT is the only one the window needs"
|
||||
);
|
||||
assert_eq!(
|
||||
Instant::now(),
|
||||
until + Duration::from_millis(PUNCH_GRACE),
|
||||
"must return when the grace runs out, not a backoff later"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,11 +211,21 @@ pub async fn create_tcp_connection(
|
||||
let sk = sign::SecretKey(sk_);
|
||||
let mut msg_out = Message::new();
|
||||
let (our_pk_b, our_sk_b) = box_::gen_keypair();
|
||||
// On a WebRTC transport, bind our DTLS certificate fingerprint to our signed identity so
|
||||
// the controller can verify the DTLS channel it negotiated actually terminates at us
|
||||
// (not a rendezvous/relay that swapped the SDP fingerprint). Empty on other transports.
|
||||
// Fail immediately on WebRTC if the local fingerprint is unavailable: signing "" would
|
||||
// only make the client fail-closed after a wasted round-trip.
|
||||
let dtls_fingerprint = stream.dtls_fingerprint(true).await.unwrap_or_default();
|
||||
if stream.is_webrtc() && dtls_fingerprint.is_empty() {
|
||||
bail!("WebRTC local DTLS fingerprint unavailable");
|
||||
}
|
||||
msg_out.set_signed_id(SignedId {
|
||||
id: sign::sign(
|
||||
&IdPk {
|
||||
id: Config::get_id(),
|
||||
pk: Bytes::from(our_pk_b.0.to_vec()),
|
||||
dtls_fingerprint,
|
||||
..Default::default()
|
||||
}
|
||||
.write_to_bytes()
|
||||
|
||||
@@ -53,6 +53,82 @@ struct WaylandUinputRect {
|
||||
struct WaylandLayout {
|
||||
baseline: Vec<scrap::wayland::display::DisplayRect>,
|
||||
live: Vec<scrap::wayland::display::DisplayRect>,
|
||||
// What the live capturers were built against. Separate from `baseline` because a session
|
||||
// init resets that one, and the generation detector needs a memory that a reset cannot
|
||||
// erase: two inits straddling a rotation would otherwise leave nothing to compare against.
|
||||
seen: Vec<scrap::wayland::display::DisplayRect>,
|
||||
// A capturer recorded a build layout other than `seen`, tagged with the generation it was
|
||||
// built at: the poll observed the live layout between that capturer's snapshot read and its
|
||||
// record, so one of the two is stale and the next poll owes an edge whatever it sees. Only
|
||||
// while that generation is current: the record can also land between the poll consuming an
|
||||
// edge and the bump it promotes (or after the bump, with a snapshot from before it), and that
|
||||
// capturer rebuilds on its own, so a second promotion would tear the fresh ones down again.
|
||||
// Consumed by `observe`, which the poll runs right after `edge`; a session init's baseline
|
||||
// reset leaves it alone.
|
||||
unseen_build: Option<u64>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl WaylandLayout {
|
||||
// Replace the per-session input baseline. Before the first poll the outgoing baseline is
|
||||
// the only record of the layout the capturers were built against, so it seeds `seen`.
|
||||
fn reset_baseline(&mut self, baseline: Vec<scrap::wayland::display::DisplayRect>) {
|
||||
if self.seen.is_empty() {
|
||||
let previous = std::mem::take(&mut self.baseline);
|
||||
self.seen = previous;
|
||||
}
|
||||
self.baseline = baseline;
|
||||
self.live.clear();
|
||||
}
|
||||
|
||||
// An EDGE (live vs the layout the capturers were built against), not a level: comparing
|
||||
// against the baseline latches true for the whole session. With nothing observed yet the
|
||||
// baseline is that record, and a missing snapshot at init makes the first success the edge,
|
||||
// or transform=0 sticks.
|
||||
fn edge(
|
||||
&self,
|
||||
live: &[scrap::wayland::display::DisplayRect],
|
||||
snapshot_missing: bool,
|
||||
generation: u64,
|
||||
) -> bool {
|
||||
if self.unseen_build == Some(generation) {
|
||||
return true;
|
||||
}
|
||||
if !self.seen.is_empty() {
|
||||
return self.seen != live;
|
||||
}
|
||||
if self.baseline.is_empty() {
|
||||
return snapshot_missing;
|
||||
}
|
||||
self.baseline != live
|
||||
}
|
||||
|
||||
fn observe(&mut self, live: &[scrap::wayland::display::DisplayRect]) {
|
||||
self.live = live.to_vec();
|
||||
self.seen = live.to_vec();
|
||||
self.unseen_build = None;
|
||||
}
|
||||
|
||||
// What a capturer was built against, which seeds the memory when nothing else has. A session
|
||||
// init whose wayland query failed leaves an EMPTY baseline, and the capturer's own retry can
|
||||
// then succeed - so the capturer is the only thing that knows the layout it is showing, and
|
||||
// without this a rotation before the first poll is invisible to `edge`. Only when empty: a
|
||||
// capturer built later must not overwrite the memory the poll is keeping, since on a
|
||||
// multi-display session that memory is what the OTHER capturers were built against. A build
|
||||
// that disagrees with it is flagged instead: the capturer's snapshot read and this record
|
||||
// are two steps, and a poll landing between them observes the live layout first, which
|
||||
// would otherwise drop the record and leave the capturer on a transform nothing compares.
|
||||
fn note_capturer(&mut self, built_on: &[scrap::wayland::display::DisplayRect], built_gen: u64) {
|
||||
if built_on.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.seen.is_empty() {
|
||||
self.seen = built_on.to_vec();
|
||||
} else if self.seen != built_on {
|
||||
// The newest generation wins: a stale record landing late must not hide a fresh one.
|
||||
self.unseen_build = Some(self.unseen_build.map_or(built_gen, |g| g.max(built_gen)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whether `live` differs from `baseline`. Read on every mouse move, so it is an atomic:
|
||||
@@ -75,9 +151,24 @@ pub(super) fn wayland_uinput_rect() -> Option<(i32, i32, i32, i32)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn set_wayland_layout_baseline(baseline: Vec<scrap::wayland::display::DisplayRect>) {
|
||||
WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed);
|
||||
let mut lock = WAYLAND_LAYOUT.lock().unwrap();
|
||||
lock.baseline = baseline;
|
||||
lock.live.clear();
|
||||
WAYLAND_LAYOUT.lock().unwrap().reset_baseline(baseline);
|
||||
}
|
||||
|
||||
/// Record the layout a capturer was just built against, and the snapshot generation it read
|
||||
/// before taking that layout. See `WaylandLayout::note_capturer`.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub(super) fn note_capturer_layout(
|
||||
displays: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
||||
built_gen: u64,
|
||||
) {
|
||||
if displays.is_empty() {
|
||||
return;
|
||||
}
|
||||
let rects = scrap::wayland::display::logical_rects_of_displays(displays);
|
||||
WAYLAND_LAYOUT
|
||||
.lock()
|
||||
.unwrap()
|
||||
.note_capturer(&rects, built_gen);
|
||||
}
|
||||
|
||||
// Remap an injected coordinate onto the live compositor layout when it has drifted from
|
||||
@@ -100,11 +191,6 @@ fn refresh_wayland_uinput_rect_if_changed() {
|
||||
if is_x11() || !crate::input_service::wayland_use_uinput() {
|
||||
return;
|
||||
}
|
||||
// Nothing to poll at a login screen; the DRM path owns the rect there.
|
||||
#[cfg(feature = "drm")]
|
||||
if crate::platform::linux::is_login_screen_wayland_cached() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap();
|
||||
if let Some(last_check) = lock.last_check {
|
||||
@@ -120,14 +206,55 @@ fn refresh_wayland_uinput_rect_if_changed() {
|
||||
// Refresh the per-display layout every poll: monitor origins can shift (e.g. two
|
||||
// displays swap positions) without changing the overall desktop rect, and the mouse
|
||||
// path needs the current per-display geometry to correct coordinates.
|
||||
let drifted = {
|
||||
let (live_changed, mut drifted) = {
|
||||
let mut layout = WAYLAND_LAYOUT.lock().unwrap();
|
||||
#[cfg(feature = "drm")]
|
||||
let snapshot_missing = scrap::wayland::display::wayland_snapshot_missing();
|
||||
#[cfg(not(feature = "drm"))]
|
||||
let snapshot_missing = false;
|
||||
#[cfg(feature = "drm")]
|
||||
let generation = scrap::wayland::display::wayland_snapshot_generation();
|
||||
#[cfg(not(feature = "drm"))]
|
||||
let generation = 0;
|
||||
let live_changed = layout.edge(&live_rects, snapshot_missing, generation);
|
||||
let drifted = !layout.baseline.is_empty()
|
||||
&& !live_rects.is_empty()
|
||||
&& layout.baseline != live_rects;
|
||||
layout.live = live_rects;
|
||||
drifted
|
||||
layout.observe(&live_rects);
|
||||
(live_changed, drifted)
|
||||
};
|
||||
// Single owner of the generation bump: on the cache clear it let every session init tear
|
||||
// down every other live capturer. Baseline promotes with the clear (rustdesk#15601).
|
||||
#[cfg(feature = "drm")]
|
||||
{
|
||||
// An edge seen while DRM is transiently non-Available stays OWED rather than consumed.
|
||||
static PROMOTION_OWED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
// The latch fires when a capturer was built with no wayland snapshot: a later cache
|
||||
// refill makes wayland_snapshot_missing lie, so live_changed alone would miss it. Taken
|
||||
// UNCONDITIONALLY: short-circuiting past it on a live_changed poll would leave it set and
|
||||
// spend a second, spurious promotion one poll later on the freshly rebuilt capturer.
|
||||
let blind_build = super::drm_capturer::take_unrotated_snapshot_pending();
|
||||
if live_changed || blind_build {
|
||||
PROMOTION_OWED.store(true, Ordering::Release);
|
||||
}
|
||||
if PROMOTION_OWED.load(Ordering::Acquire) && super::drm_capturer::is_available_cached() {
|
||||
PROMOTION_OWED.store(false, Ordering::Release);
|
||||
scrap::wayland::display::clear_wayland_displays_cache();
|
||||
scrap::wayland::display::bump_layout_generation();
|
||||
set_wayland_layout_baseline(live_rects.clone());
|
||||
WAYLAND_LAYOUT.lock().unwrap().live = live_rects.clone();
|
||||
drifted = false;
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "drm"))]
|
||||
let _ = live_changed;
|
||||
// At a login screen the DRM path owns the rect; only the range/remap update is skipped,
|
||||
// the snapshot invalidation above must still run (a greeter session has no other trigger).
|
||||
#[cfg(feature = "drm")]
|
||||
if crate::platform::linux::is_login_screen_wayland_cached() {
|
||||
return;
|
||||
}
|
||||
// The remap corrects for per-display origin shifts; the uinput ABS range corrects for
|
||||
// the overall bounding box. Only enable the remap once the range matches the live
|
||||
// layout, otherwise moves would be remapped into a range the device is not yet using.
|
||||
@@ -721,3 +848,177 @@ mod tests {
|
||||
assert_eq!(normalize_primary_display_idx(2, 2), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod wayland_layout_tests {
|
||||
use super::WaylandLayout;
|
||||
use scrap::wayland::display::DisplayRect;
|
||||
|
||||
fn layout(w: i32, h: i32, transform: i32) -> Vec<DisplayRect> {
|
||||
vec![DisplayRect {
|
||||
name: "DP-1".into(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
w,
|
||||
h,
|
||||
transform,
|
||||
}]
|
||||
}
|
||||
|
||||
// rustdesk#15886: a video service starts, the output rotates, and a retry starts before the
|
||||
// 1.5 s poll. The baseline is reset on both, so it cannot be the edge detector's memory.
|
||||
#[test]
|
||||
fn a_rotation_between_two_session_inits_is_still_an_edge() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(upright.clone());
|
||||
l.observe(&upright);
|
||||
l.reset_baseline(upright.clone());
|
||||
l.reset_baseline(rotated.clone());
|
||||
assert!(l.edge(&rotated, false, 0));
|
||||
}
|
||||
|
||||
// The same, with no poll ever having run: the outgoing baseline is the only record of what
|
||||
// the first capturer was built against.
|
||||
#[test]
|
||||
fn a_rotation_between_two_inits_before_the_first_poll_is_still_an_edge() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(upright.clone());
|
||||
l.reset_baseline(rotated.clone());
|
||||
assert!(l.edge(&rotated, false, 0));
|
||||
}
|
||||
|
||||
// Control: without it the asserts above would pass on a detector that always fires.
|
||||
#[test]
|
||||
fn repeated_baseline_resets_without_a_rotation_are_not_an_edge() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(upright.clone());
|
||||
l.observe(&upright);
|
||||
l.reset_baseline(upright.clone());
|
||||
l.reset_baseline(upright.clone());
|
||||
assert!(!l.edge(&upright, false, 0));
|
||||
}
|
||||
|
||||
// rustdesk#15886: `ensure_inited()` runs the wayland query BEFORE the capturer exists, and a
|
||||
// failure there saves an EMPTY baseline. The capturer's own retry can succeed a moment later
|
||||
// and build on layout A, and that build is not blind, so nothing else records it. A rotation
|
||||
// before the first poll then had no memory to be an edge against.
|
||||
#[test]
|
||||
fn a_capturer_built_after_a_failed_init_still_owes_a_rebuild() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(Vec::new());
|
||||
l.note_capturer(&upright, 0);
|
||||
assert!(l.edge(&rotated, false, 0));
|
||||
|
||||
// The same with another baseline reset between the build and the poll.
|
||||
let mut l2 = WaylandLayout::default();
|
||||
l2.reset_baseline(Vec::new());
|
||||
l2.note_capturer(&upright, 0);
|
||||
l2.reset_baseline(rotated.clone());
|
||||
assert!(l2.edge(&rotated, false, 0));
|
||||
|
||||
// Control: no rotation, no edge, in both shapes.
|
||||
let mut l3 = WaylandLayout::default();
|
||||
l3.reset_baseline(Vec::new());
|
||||
l3.note_capturer(&upright, 0);
|
||||
assert!(!l3.edge(&upright, false, 0));
|
||||
}
|
||||
|
||||
// A capturer built while the poll already has a memory must not overwrite it.
|
||||
#[test]
|
||||
fn a_later_capturer_does_not_overwrite_the_polls_memory() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.observe(&upright);
|
||||
l.note_capturer(&rotated, 0);
|
||||
assert!(l.edge(&rotated, false, 0), "the poll's memory still says upright");
|
||||
}
|
||||
|
||||
// The constructor's snapshot read and its `note_capturer` are two steps, and the poll can
|
||||
// land between them. After a failed init (empty baseline) the constructor takes A and
|
||||
// publishes it; the output rotates; the poll reads B live, finds nothing recorded and the
|
||||
// snapshot present, so no edge, and observes B. The late `note_capturer(A)` then met a
|
||||
// non-empty memory and was dropped: the capturer showed A while the detector held B, and B
|
||||
// against B never bumped the generation.
|
||||
#[test]
|
||||
fn a_capturer_record_that_lost_the_race_with_the_first_poll_is_still_an_edge() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(Vec::new());
|
||||
assert!(!l.edge(&rotated, false, 0), "nothing recorded and the snapshot is present");
|
||||
l.observe(&rotated);
|
||||
l.note_capturer(&upright, 0);
|
||||
assert!(l.edge(&rotated, false, 0), "the capturer is built on upright, live is rotated");
|
||||
|
||||
// The promotion consumes it: the next poll sees the same layout and stays quiet.
|
||||
l.observe(&rotated);
|
||||
l.reset_baseline(rotated.clone());
|
||||
assert!(!l.edge(&rotated, false, 0));
|
||||
|
||||
// The same with a session init between the late record and the poll.
|
||||
let mut l2 = WaylandLayout::default();
|
||||
l2.reset_baseline(Vec::new());
|
||||
l2.observe(&rotated);
|
||||
l2.note_capturer(&upright, 0);
|
||||
l2.reset_baseline(rotated.clone());
|
||||
assert!(l2.edge(&rotated, false, 0));
|
||||
|
||||
// Control: a late record that agrees with the poll's memory is not an edge.
|
||||
let mut l3 = WaylandLayout::default();
|
||||
l3.reset_baseline(Vec::new());
|
||||
l3.observe(&upright);
|
||||
l3.note_capturer(&upright, 0);
|
||||
assert!(!l3.edge(&upright, false, 0));
|
||||
}
|
||||
|
||||
// The late record can also land after the poll consumed the edge but before the bump that
|
||||
// edge promotes, or after the bump with a snapshot taken before it. That capturer is stale
|
||||
// by generation and rebuilds on its own, so its record must not buy a second promotion
|
||||
// that tears the freshly rebuilt capturers down again.
|
||||
#[test]
|
||||
fn a_late_record_from_a_generation_already_promoted_is_not_a_second_edge() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(upright.clone());
|
||||
l.observe(&upright);
|
||||
// The output rotates, the poll consumes the edge, the capturer built on upright at
|
||||
// generation 7 records late, and the poll promotes to 8.
|
||||
assert!(l.edge(&rotated, false, 7));
|
||||
l.observe(&rotated);
|
||||
l.note_capturer(&upright, 7);
|
||||
l.reset_baseline(rotated.clone());
|
||||
assert!(!l.edge(&rotated, false, 8), "the capturer built at 7 rebuilds on its own");
|
||||
|
||||
// Control: a disagreeing record AT the promoted generation is a real edge.
|
||||
l.observe(&rotated);
|
||||
l.note_capturer(&upright, 8);
|
||||
assert!(l.edge(&rotated, false, 8));
|
||||
|
||||
// A stale record landing after a fresh one must not hide the fresh one.
|
||||
l.observe(&rotated);
|
||||
l.note_capturer(&upright, 8);
|
||||
l.note_capturer(&upright, 7);
|
||||
assert!(l.edge(&rotated, false, 8));
|
||||
}
|
||||
|
||||
// A promotion consumes the edge: the next poll sees the same layout and must stay quiet.
|
||||
#[test]
|
||||
fn a_promoted_layout_is_not_an_edge_again() {
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(layout(1920, 1080, 0));
|
||||
l.observe(&rotated);
|
||||
l.reset_baseline(rotated.clone());
|
||||
assert!(!l.edge(&rotated, false, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,17 @@ impl FrameSlot {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Shared.transform` before new() stores the real value: a cursor arriving this early is held
|
||||
/// back and replayed once the session transform is in, because the producer will not resend it
|
||||
/// until the shape changes.
|
||||
const TRANSFORM_PENDING: i32 = i32::MIN;
|
||||
|
||||
struct Shared {
|
||||
slot: Mutex<FrameSlot>,
|
||||
cv: Condvar,
|
||||
// Session transform, TRANSFORM_PENDING until new() stores it post-handshake; the receive
|
||||
// thread turns cursor bitmaps with it and defers any cursor that races the store.
|
||||
transform: std::sync::atomic::AtomicI32,
|
||||
}
|
||||
|
||||
pub struct IpcDrmCapturer {
|
||||
@@ -63,7 +71,14 @@ pub struct IpcDrmCapturer {
|
||||
display: i32,
|
||||
connector: Option<String>,
|
||||
// What the encoder was sized from: CapturerInfo{width,height} is read once, at build time.
|
||||
// With a rotated output these are the ROTATED dimensions, matching the frames delivered.
|
||||
session_size: Option<(usize, usize)>,
|
||||
// Output rotation in degrees: a rotated scanout holds the desktop drawn sideways, so frames
|
||||
// are turned back before delivery. Fixed per session; a rotation rebuilds the capturer.
|
||||
transform: i32,
|
||||
// The wayland snapshot generation this session was built from: a later invalidation means
|
||||
// the layout (a rotation included) may have changed, and frame() asks for a rebuild.
|
||||
snapshot_gen: u64,
|
||||
cur: Vec<u8>,
|
||||
cur_w: usize,
|
||||
cur_h: usize,
|
||||
@@ -76,6 +91,102 @@ fn connector_key(d: &DrmDisplayInfo) -> String {
|
||||
format!("{}:{}", d.device, d.name)
|
||||
}
|
||||
|
||||
/// Frame dimensions after undoing `transform` degrees of output rotation.
|
||||
fn rotated_dims(transform: i32, w: usize, h: usize) -> (usize, usize) {
|
||||
if transform == 90 || transform == 270 {
|
||||
(h, w)
|
||||
} else {
|
||||
(w, h)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hotspot of a rotated cursor bitmap: the same point mapping `unrotate_bgra` applies to
|
||||
/// pixels, applied to the one coordinate that must keep naming the click point.
|
||||
fn unrotate_hotspot(transform: i32, w: i32, h: i32, hotx: i32, hoty: i32) -> (i32, i32) {
|
||||
match transform {
|
||||
90 => (h - 1 - hoty, hotx),
|
||||
180 => (w - 1 - hotx, h - 1 - hoty),
|
||||
270 => (hoty, w - 1 - hotx),
|
||||
_ => (hotx, hoty),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a 4-byte-pixel frame upright into tightly packed `dst`, undoing `transform` degrees;
|
||||
/// padded `src` rows ok (stride = len/h). Direction pinned by the tests to the measured anchor
|
||||
/// of rustdesk#15886; libyuv walks pixels, so channel order does not matter.
|
||||
fn unrotate_bgra(src: &[u8], w: usize, h: usize, transform: i32, dst: &mut Vec<u8>) {
|
||||
const PX: usize = 4;
|
||||
let stride = if h > 0 { src.len() / h } else { 0 };
|
||||
let (dw, dh) = rotated_dims(transform, w, h);
|
||||
dst.resize(
|
||||
dw.checked_mul(dh).and_then(|p| p.checked_mul(PX)).unwrap_or(0),
|
||||
0,
|
||||
);
|
||||
if dst.is_empty() || stride < w * PX {
|
||||
log::error!("unrotate: rejected geometry {w}x{h} stride {stride}; frame left blank");
|
||||
return;
|
||||
}
|
||||
let mode = match transform {
|
||||
90 => scrap::RotationMode::kRotate90,
|
||||
180 => scrap::RotationMode::kRotate180,
|
||||
270 => scrap::RotationMode::kRotate270,
|
||||
_ => scrap::RotationMode::kRotate0,
|
||||
};
|
||||
unsafe {
|
||||
scrap::ARGBRotate(
|
||||
src.as_ptr(),
|
||||
stride as i32,
|
||||
dst.as_mut_ptr(),
|
||||
(dw * PX) as i32,
|
||||
w as i32,
|
||||
h as i32,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform and augmented origin for one wire entry, derived from ONE wayland snapshot so both
|
||||
/// reflect the same output assignment; two `get_displays()` reads could straddle a cache
|
||||
/// invalidation. `None` origin means nothing to augment with (caller keeps the DRM origin).
|
||||
fn transform_and_origin(
|
||||
drm: &[DrmDisplayInfo],
|
||||
wire_idx: usize,
|
||||
wl: &scrap::wayland::display::Displays,
|
||||
) -> (i32, Option<(i32, i32)>) {
|
||||
if wl.displays.is_empty() || (wl.displays.len() == 1 && drm.len() > 1) {
|
||||
if wl.displays.is_empty() && !drm.is_empty() {
|
||||
// A later successful enumeration refills the cache and hides this state from
|
||||
// wayland_snapshot_missing, so the layout poll needs this durable record to know a
|
||||
// capturer was built blind and owes a rebuild.
|
||||
UNROTATED_SNAPSHOT_PENDING.store(true, Ordering::Release);
|
||||
log::warn!(
|
||||
"drm: no wayland snapshot at capturer build for display {:?}; assuming unrotated",
|
||||
drm.get(wire_idx).map(|d| d.name.as_str()).unwrap_or("?")
|
||||
);
|
||||
}
|
||||
return (0, None);
|
||||
}
|
||||
let assignment = assign_wayland_outputs(drm, &wl.displays);
|
||||
// The transform comes ONLY from an identity match (name, or unique resolution), through the
|
||||
// SAME progressive-taken pass the advertise side keys its swap off: the layout-order
|
||||
// fallback is fine for an origin guess, but a rotation pinned on a guess splits the
|
||||
// advertised dimensions from the delivered ones.
|
||||
let transform = identity_matches(drm, &wl.displays)
|
||||
.get(wire_idx)
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(|j| wl.displays[j].transform)
|
||||
// Hardware-rotated 180 scans out already upright (i915 advertises rotate-180 and
|
||||
// mutter uses it), and wl_output cannot tell hardware from software rotation, so 180
|
||||
// keeps master behavior until the plane rotation property travels the wire.
|
||||
.map(|t| if t == 90 || t == 270 { t } else { 0 })
|
||||
.unwrap_or(0);
|
||||
let origin = augment_with_wayland_geometry_from(drm, wl, &assignment)
|
||||
.get(wire_idx)
|
||||
.map(|di| (di.x, di.y));
|
||||
(transform, origin)
|
||||
}
|
||||
|
||||
/// Takes DRM_STATE: never call it while holding one of the per-display maps below.
|
||||
fn display_info_of(display: i32) -> Option<DrmDisplayInfo> {
|
||||
match &*DRM_STATE.lock().unwrap() {
|
||||
@@ -96,6 +207,9 @@ struct DisplayHealth {
|
||||
/// The dma-buf convert failed for this display. The COMMON cause is multi-GPU: our render node
|
||||
/// is not the GPU that exported the scanout. Follows the monitor for the process run.
|
||||
prefer_cpu: bool,
|
||||
/// The PipeWire fallback for this display was rejected on geometry (a transposed stream), so
|
||||
/// the lone-display carve-out in `mark_demoted_displays` must not keep advertising it online.
|
||||
fallback_rejected: bool,
|
||||
}
|
||||
|
||||
impl DisplayHealth {
|
||||
@@ -107,6 +221,7 @@ impl DisplayHealth {
|
||||
last_build: None,
|
||||
rapid_builds: 0,
|
||||
prefer_cpu: false,
|
||||
fallback_rejected: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +300,14 @@ fn render_node_count() -> usize {
|
||||
}
|
||||
|
||||
static UINPUT_REFRESH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
/// A capturer was built with no wayland snapshot and runs unrotated; the layout poll consumes
|
||||
/// this to bump the generation once a live snapshot exists.
|
||||
static UNROTATED_SNAPSHOT_PENDING: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
pub(super) fn take_unrotated_snapshot_pending() -> bool {
|
||||
UNROTATED_SNAPSHOT_PENDING.swap(false, std::sync::atomic::Ordering::AcqRel)
|
||||
}
|
||||
static UINPUT_REFRESH_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
impl IpcDrmCapturer {
|
||||
@@ -193,7 +316,7 @@ impl IpcDrmCapturer {
|
||||
pub fn new(
|
||||
display: i32,
|
||||
expected: Option<DrmDisplayInfo>,
|
||||
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize)> {
|
||||
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize, Option<(i32, i32)>)> {
|
||||
let shared = Arc::new(Shared {
|
||||
slot: Mutex::new(FrameSlot {
|
||||
latest: None,
|
||||
@@ -201,6 +324,7 @@ impl IpcDrmCapturer {
|
||||
ended: None,
|
||||
}),
|
||||
cv: Condvar::new(),
|
||||
transform: std::sync::atomic::AtomicI32::new(TRANSFORM_PENDING),
|
||||
});
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (tx, rx) = std::sync::mpsc::channel::<ResultType<(Vec<DrmDisplayInfo>, usize)>>();
|
||||
@@ -220,6 +344,18 @@ impl IpcDrmCapturer {
|
||||
bail!("drm capture handshake timed out");
|
||||
}
|
||||
};
|
||||
// One snapshot for the session: transform, origin and the advertised swap must all
|
||||
// reflect the same output assignment. The generation is read BEFORE the snapshot, so a
|
||||
// clear racing the build rebuilds once instead of running a session on stale geometry.
|
||||
let snapshot_gen = scrap::wayland::display::wayland_snapshot_generation();
|
||||
let wl = scrap::wayland::display::get_displays();
|
||||
let (transform, origin) = transform_and_origin(&displays, wire_idx, &wl);
|
||||
// This capturer now shows that layout. If the session init's own wayland query failed it
|
||||
// saved an empty baseline, so this is the only record of what the stream is built on.
|
||||
super::display_service::note_capturer_layout(&wl.displays, snapshot_gen);
|
||||
shared
|
||||
.transform
|
||||
.store(transform, std::sync::atomic::Ordering::Release);
|
||||
Ok((
|
||||
IpcDrmCapturer {
|
||||
shared,
|
||||
@@ -228,7 +364,9 @@ impl IpcDrmCapturer {
|
||||
connector: displays.get(wire_idx).map(connector_key),
|
||||
session_size: displays
|
||||
.get(wire_idx)
|
||||
.map(|d| (d.width as usize, d.height as usize)),
|
||||
.map(|d| rotated_dims(transform, d.width as usize, d.height as usize)),
|
||||
transform,
|
||||
snapshot_gen,
|
||||
cur: Vec::new(),
|
||||
cur_w: 0,
|
||||
cur_h: 0,
|
||||
@@ -237,6 +375,7 @@ impl IpcDrmCapturer {
|
||||
},
|
||||
displays,
|
||||
wire_idx,
|
||||
origin,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -294,10 +433,21 @@ impl TraitCapturer for IpcDrmCapturer {
|
||||
}
|
||||
if let Some((w, h, fmt, buf)) = slot.latest.take() {
|
||||
drop(slot);
|
||||
// convert_to_yuv only refuses a source LARGER than its destination, so a smaller
|
||||
// frame leaves stale edges on screen. On the FIRST frame nothing changed: the list
|
||||
// carries the CRTC mode, a frame the scanout fb, different when a CRTC scales.
|
||||
if self.session_size.is_some_and(|(sw, sh)| (w, h) != (sw, sh)) {
|
||||
// A layout change bumps the generation and is otherwise invisible here (mode
|
||||
// and framebuffer keep their size). Rebuild for the new transform; not counted
|
||||
// against health: the layout moved, the display did not fail.
|
||||
if scrap::wayland::display::wayland_snapshot_generation() != self.snapshot_gen {
|
||||
self.shared.slot.lock().unwrap().recycle(buf);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("drm: display {} layout changed; rebuilding", self.display),
|
||||
));
|
||||
}
|
||||
// Frames arrive in scanout orientation, the session was sized rotated, so the
|
||||
// guard compares rotated dims. convert_to_yuv only refuses a LARGER source (a
|
||||
// smaller one leaves stale edges); first frame: CRTC mode vs scanout fb.
|
||||
let (fw, fh) = rotated_dims(self.transform, w, h);
|
||||
if self.session_size.is_some_and(|(sw, sh)| (fw, fh) != (sw, sh)) {
|
||||
self.shared.slot.lock().unwrap().recycle(buf);
|
||||
if !self.got_frame {
|
||||
self.note_session_without_frame();
|
||||
@@ -311,15 +461,35 @@ impl TraitCapturer for IpcDrmCapturer {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"drm: display {} {what} ({sw}x{sh} -> {w}x{h}); rebuilding",
|
||||
"drm: display {} {what} ({sw}x{sh} -> {fw}x{fh}); rebuilding",
|
||||
self.display
|
||||
),
|
||||
));
|
||||
}
|
||||
let previous = std::mem::replace(&mut self.cur, buf);
|
||||
self.shared.slot.lock().unwrap().recycle(previous);
|
||||
self.cur_w = w;
|
||||
self.cur_h = h;
|
||||
if self.transform == 0 {
|
||||
let previous = std::mem::replace(&mut self.cur, buf);
|
||||
self.shared.slot.lock().unwrap().recycle(previous);
|
||||
} else if !matches!(fmt, Pixfmt::BGRA | Pixfmt::RGBA) {
|
||||
// Unreachable with today's producers (the convert path emits 4-byte pixels
|
||||
// and the CPU path hardcodes BGRA); kept so a future non-4-byte producer
|
||||
// fails the session instead of shearing the image.
|
||||
self.shared.slot.lock().unwrap().recycle(buf);
|
||||
if !self.got_frame {
|
||||
self.note_session_without_frame();
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"drm: display {} delivered {fmt:?} on a rotated output; rebuilding",
|
||||
self.display
|
||||
),
|
||||
));
|
||||
} else {
|
||||
unrotate_bgra(&buf, w, h, self.transform, &mut self.cur);
|
||||
self.shared.slot.lock().unwrap().recycle(buf);
|
||||
}
|
||||
self.cur_w = fw;
|
||||
self.cur_h = fh;
|
||||
self.cur_fmt = fmt;
|
||||
if !self.got_frame {
|
||||
// Clear ONLY the streak: `rapid_builds` is for a display that delivers a first
|
||||
@@ -330,6 +500,7 @@ impl TraitCapturer for IpcDrmCapturer {
|
||||
h.zero_frame_streak = 0;
|
||||
h.demotes = 0;
|
||||
h.since = Instant::now();
|
||||
h.fallback_rejected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -460,10 +631,21 @@ async fn recv_thread(
|
||||
}
|
||||
let _ = tx.send(Ok((displays, wire_idx)));
|
||||
|
||||
// A cursor that arrived before new() stored the session transform, held for replay. Only the
|
||||
// newest matters; the 200 ms recv timeout guarantees this is retried even on an idle wire.
|
||||
let mut pending_cursor: Option<(u64, u32, u32, i32, i32, Vec<u8>)> = None;
|
||||
let end_reason = loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break "stopped".to_owned();
|
||||
}
|
||||
if pending_cursor.is_some() {
|
||||
let t = shared.transform.load(std::sync::atomic::Ordering::Acquire);
|
||||
if t != TRANSFORM_PENDING {
|
||||
if let Some((id, width, height, hotx, hoty, raw)) = pending_cursor.take() {
|
||||
deliver_drm_cursor(display, cursor_epoch, id, width, height, hotx, hoty, raw, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (msg, recv_fd) = match conn.recv_msg_timeout2(200).await {
|
||||
None => continue, // timeout: re-check stop at the loop top
|
||||
Some(Ok(pair)) => pair,
|
||||
@@ -580,18 +762,23 @@ async fn recv_thread(
|
||||
raw.len()
|
||||
);
|
||||
}
|
||||
set_drm_cursor(
|
||||
display,
|
||||
cursor_epoch,
|
||||
DrmCursorData {
|
||||
let t = shared.transform.load(std::sync::atomic::Ordering::Acquire);
|
||||
if t == TRANSFORM_PENDING {
|
||||
pending_cursor = Some((id, width, height, hotx, hoty, raw));
|
||||
} else {
|
||||
pending_cursor = None;
|
||||
deliver_drm_cursor(
|
||||
display,
|
||||
cursor_epoch,
|
||||
id,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
width,
|
||||
height,
|
||||
hotx,
|
||||
hoty,
|
||||
colors: raw,
|
||||
},
|
||||
);
|
||||
raw,
|
||||
t,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => break format!("cursor body: {err}"),
|
||||
}
|
||||
@@ -717,6 +904,56 @@ fn remove_drm_cursor(display: i32, epoch: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unrotate a wire cursor into the session orientation and publish it. The compositor
|
||||
/// pre-rotates the bitmap it programs into the cursor plane, so over the unrotated video the
|
||||
/// cursor alone would stay turned and its hotspot transposed (review finding 11 on
|
||||
/// rustdesk#15889). The wire id hashes only the plane pixels and geometry, so a stream rebuilt
|
||||
/// under a new transform resends the SAME id and the client's by-id cursor cache would keep the
|
||||
/// old orientation: fold the transform in (the producer's own FNV step) so id and orientation
|
||||
/// can never disagree. The hidden sentinel must survive untouched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn deliver_drm_cursor(
|
||||
display: i32,
|
||||
cursor_epoch: u64,
|
||||
id: u64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
hotx: i32,
|
||||
hoty: i32,
|
||||
raw: Vec<u8>,
|
||||
t: i32,
|
||||
) {
|
||||
let (width, height, hotx, hoty, colors) = if t == 90 || t == 270 {
|
||||
let mut turned = Vec::new();
|
||||
unrotate_bgra(&raw, width as usize, height as usize, t, &mut turned);
|
||||
let (hx, hy) = unrotate_hotspot(t, width as i32, height as i32, hotx, hoty);
|
||||
(height as i32, width as i32, hx, hy, turned)
|
||||
} else {
|
||||
(width as i32, height as i32, hotx, hoty, raw)
|
||||
};
|
||||
let id = fold_cursor_id(id, t);
|
||||
set_drm_cursor(
|
||||
display,
|
||||
cursor_epoch,
|
||||
DrmCursorData {
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
hotx,
|
||||
hoty,
|
||||
colors,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn fold_cursor_id(id: u64, t: i32) -> u64 {
|
||||
if id == scrap::drm_reader::HIDDEN_CURSOR_ID {
|
||||
id
|
||||
} else {
|
||||
(id ^ t as u32 as u64).wrapping_mul(1099511628211)
|
||||
}
|
||||
}
|
||||
|
||||
fn with_drm_cursor<T>(f: impl Fn(&DrmCursorData) -> T) -> Option<T> {
|
||||
let map = DRM_CURSOR.lock().unwrap();
|
||||
map.values()
|
||||
@@ -1183,12 +1420,22 @@ pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> {
|
||||
}
|
||||
|
||||
// A multi-display portal stream cannot replace one demoted connector. Keep its index but mark it
|
||||
// offline; a single connector remains usable through the whole-desktop fallback.
|
||||
// offline; a single connector remains usable through the whole-desktop fallback - unless that
|
||||
// fallback itself was rejected on geometry, in which case advertising the lone display online
|
||||
// would restart-loop the video service against a stream nothing can serve.
|
||||
fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) {
|
||||
let health = DRM_DISPLAY_HEALTH.lock().unwrap();
|
||||
if list.len() <= 1 {
|
||||
if let (Some(display), Some(info)) = (list.first(), infos.first_mut()) {
|
||||
if health
|
||||
.get(&connector_key(display))
|
||||
.is_some_and(|health| health.demoted() && health.fallback_rejected)
|
||||
{
|
||||
info.online = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let health = DRM_DISPLAY_HEALTH.lock().unwrap();
|
||||
for (display, info) in list.iter().zip(infos.iter_mut()) {
|
||||
if health
|
||||
.get(&connector_key(display))
|
||||
@@ -1199,6 +1446,21 @@ fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The PipeWire fallback for this display was rejected on geometry; recorded so the lone-display
|
||||
/// carve-out above stops advertising a display nothing can serve. Cleared by a delivered frame
|
||||
/// and by the demote-cooldown re-arm.
|
||||
pub(super) fn mark_fallback_rejected(display_idx: usize) {
|
||||
let Some(expected) = display_info_of(display_idx as i32) else {
|
||||
return;
|
||||
};
|
||||
DRM_DISPLAY_HEALTH
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(connector_key(&expected))
|
||||
.or_insert_with(DisplayHealth::new)
|
||||
.fallback_rejected = true;
|
||||
}
|
||||
|
||||
fn primary_index_from_assignment(assignment: &[Option<usize>], primary: usize) -> usize {
|
||||
assignment
|
||||
.iter()
|
||||
@@ -1266,18 +1528,36 @@ fn augment_with_wayland_geometry_from(
|
||||
if origin_only && drm.len() > 1 {
|
||||
return infos;
|
||||
}
|
||||
let identity = identity_matches(drm, &wl.displays);
|
||||
for (i, info) in infos.iter_mut().enumerate() {
|
||||
let Some(w) = matched[i].map(|j| &wl.displays[j]) else {
|
||||
continue;
|
||||
};
|
||||
info.x = w.x;
|
||||
info.y = w.y;
|
||||
// Rotated size before the origin-only cut: a lone rotated output still delivers rotated
|
||||
// frames, so it must advertise them; only the logical-scale adoption stays multi-output.
|
||||
// original_resolution follows in the same motion, or the client reads the transposed
|
||||
// current size against an untransposed original as a third-party resolution change.
|
||||
// Identity matches ONLY, the same rule the capturer's transform follows: swapping on a
|
||||
// layout-order guess advertises dimensions the capturer will not deliver.
|
||||
let is_identity = identity[i].is_some() && identity[i] == matched[i];
|
||||
if is_identity && (w.transform == 90 || w.transform == 270) {
|
||||
std::mem::swap(&mut info.width, &mut info.height);
|
||||
info.original_resolution = super::display_service::get_original_resolution(
|
||||
&drm[i].name,
|
||||
info.width as usize,
|
||||
info.height as usize,
|
||||
);
|
||||
}
|
||||
if origin_only {
|
||||
continue;
|
||||
}
|
||||
if let Some((lw, lh)) = w.logical_size {
|
||||
if lw > 0 && lh > 0 {
|
||||
info.scale = drm[i].width as f64 / lw as f64;
|
||||
// Post-swap width over logical width, which arrives already swapped when rotated:
|
||||
// the unrotated numerator made a rotated 1:1 monitor advertise scale 16/9.
|
||||
info.scale = info.width as f64 / lw as f64;
|
||||
info.original_resolution = super::display_service::get_original_resolution(
|
||||
&drm[i].name,
|
||||
lw as usize,
|
||||
@@ -1292,18 +1572,62 @@ fn augment_with_wayland_geometry_from(
|
||||
/// Each output goes to at most one connector; unmatched ones take the next free output of the same
|
||||
/// size, else the next free one in layout order, since leaving them unaugmented keeps them all at
|
||||
/// DRM's (0,0).
|
||||
fn assign_wayland_outputs(
|
||||
/// The identity half of the assignment (name, or unique resolution), same progressive `taken`
|
||||
/// as the full one. Rotation keys off THIS on both sides: swapping or turning on a layout-order
|
||||
/// guess splits the advertised dimensions from the delivered frames.
|
||||
/// Identity assignment in two GLOBAL passes: every exact name match is reserved first, then
|
||||
/// resolution pairing runs on the unmatched remainder, and only when it is forced - exactly one
|
||||
/// free output AND exactly one unmatched connector at that resolution. A resolution guess for an
|
||||
/// earlier connector must never steal an exact name match from a later one.
|
||||
fn identity_matches(
|
||||
drm: &[DrmDisplayInfo],
|
||||
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
||||
) -> Vec<Option<usize>> {
|
||||
let mut taken = vec![false; wl.len()];
|
||||
let mut matched: Vec<Option<usize>> = vec![None; drm.len()];
|
||||
for (i, d) in drm.iter().enumerate() {
|
||||
if let Some(j) = match_wayland_display(d, wl, &taken) {
|
||||
let dn = normalize_connector(&d.name);
|
||||
if let Some((j, _)) = wl
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn)
|
||||
{
|
||||
matched[i] = Some(j);
|
||||
taken[j] = true;
|
||||
}
|
||||
}
|
||||
for (i, d) in drm.iter().enumerate() {
|
||||
if matched[i].is_some() {
|
||||
continue;
|
||||
}
|
||||
let free_same: Vec<usize> = wl
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32)
|
||||
.map(|(j, _)| j)
|
||||
.collect();
|
||||
let unmatched_same = drm
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, o)| matched[*k].is_none() && o.width == d.width && o.height == d.height)
|
||||
.count();
|
||||
if free_same.len() == 1 && unmatched_same == 1 {
|
||||
matched[i] = Some(free_same[0]);
|
||||
taken[free_same[0]] = true;
|
||||
}
|
||||
}
|
||||
matched
|
||||
}
|
||||
|
||||
fn assign_wayland_outputs(
|
||||
drm: &[DrmDisplayInfo],
|
||||
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
||||
) -> Vec<Option<usize>> {
|
||||
let mut matched = identity_matches(drm, wl);
|
||||
let mut taken = vec![false; wl.len()];
|
||||
for m in matched.iter().flatten() {
|
||||
taken[*m] = true;
|
||||
}
|
||||
for (i, d) in drm.iter().enumerate() {
|
||||
if matched[i].is_some() {
|
||||
continue;
|
||||
@@ -1329,30 +1653,6 @@ fn assign_wayland_outputs(
|
||||
matched
|
||||
}
|
||||
|
||||
fn match_wayland_display(
|
||||
d: &DrmDisplayInfo,
|
||||
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
||||
taken: &[bool],
|
||||
) -> Option<usize> {
|
||||
let dn = normalize_connector(&d.name);
|
||||
if let Some((j, _)) = wl
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn)
|
||||
{
|
||||
return Some(j);
|
||||
}
|
||||
let same_res: Vec<usize> = wl
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32)
|
||||
.map(|(j, _)| j)
|
||||
.collect();
|
||||
if same_res.len() == 1 {
|
||||
return Some(same_res[0]);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// DRM inserts a single-letter type discriminator the compositor drops ("HDMI-A-1" -> "HDMI-1").
|
||||
/// Only a *letter* folds: a single *digit* is an MST port index, so "DP-1-2" is not "DP-2".
|
||||
@@ -1413,11 +1713,13 @@ pub(super) fn get_capturer_info(
|
||||
}
|
||||
h.zero_frame_streak = 0;
|
||||
h.since = Instant::now();
|
||||
// The cooldown re-arms DRM for this display, so the fallback verdict restarts too.
|
||||
h.fallback_rejected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Built FIRST: a transient `_drm` outage must NOT count toward the flap threshold below.
|
||||
let (capturer, displays, wire_idx) = IpcDrmCapturer::new(display_idx as i32, expected)?;
|
||||
let (capturer, displays, wire_idx, origin) = IpcDrmCapturer::new(display_idx as i32, expected)?;
|
||||
// The initial build counts 0, so demotion fires on the (RAPID_REBUILD_MAX + 1)-th in a window.
|
||||
if let Some(key) = key.clone() {
|
||||
let now = Instant::now();
|
||||
@@ -1445,16 +1747,14 @@ pub(super) fn get_capturer_info(
|
||||
.get(wire_idx)
|
||||
.ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))?
|
||||
.clone();
|
||||
// Publish the compositor's LOGICAL origin (what get_display_infos advertises) so the origin
|
||||
// matches the reported geometry; KEEP the raw PHYSICAL dimensions for the capture buffer.
|
||||
let origin = augment_with_wayland_geometry(&displays)
|
||||
.get(wire_idx)
|
||||
.map(|di| (di.x, di.y))
|
||||
.unwrap_or((d.x, d.y));
|
||||
// Origin and transform come from the ONE snapshot new() resolved, so both reflect the
|
||||
// same output assignment; dimensions stay PHYSICAL, rotated to frame orientation.
|
||||
let origin = origin.unwrap_or((d.x, d.y));
|
||||
let (cap_w, cap_h) = rotated_dims(capturer.transform, d.width as usize, d.height as usize);
|
||||
Ok(super::video_service::CapturerInfo {
|
||||
origin,
|
||||
width: d.width as usize,
|
||||
height: d.height as usize,
|
||||
width: cap_w,
|
||||
height: cap_h,
|
||||
ndisplay,
|
||||
current: display_idx,
|
||||
privacy_mode_id: 0,
|
||||
@@ -1482,11 +1782,14 @@ mod drm_capturer_tests {
|
||||
ended: None,
|
||||
}),
|
||||
cv: Condvar::new(),
|
||||
transform: std::sync::atomic::AtomicI32::new(0),
|
||||
}),
|
||||
stop: Arc::new(AtomicBool::new(false)),
|
||||
display: 0,
|
||||
connector,
|
||||
session_size: session,
|
||||
transform: 0,
|
||||
snapshot_gen: scrap::wayland::display::wayland_snapshot_generation(),
|
||||
cur: Vec::new(),
|
||||
cur_w: 0,
|
||||
cur_h: 0,
|
||||
@@ -1495,6 +1798,172 @@ mod drm_capturer_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One BGRA pixel per label byte, so a rotation result reads as a matrix of labels.
|
||||
fn px_frame(labels: &[&[u8]], pad_bytes: usize) -> (Vec<u8>, usize, usize) {
|
||||
let h = labels.len();
|
||||
let w = labels[0].len();
|
||||
let mut buf = Vec::new();
|
||||
for row in labels {
|
||||
for &l in *row {
|
||||
buf.extend_from_slice(&[l, l, l, 255]);
|
||||
}
|
||||
buf.extend(std::iter::repeat(0u8).take(pad_bytes));
|
||||
}
|
||||
(buf, w, h)
|
||||
}
|
||||
|
||||
fn labels_of(buf: &[u8], w: usize, h: usize) -> Vec<Vec<u8>> {
|
||||
(0..h)
|
||||
.map(|y| (0..w).map(|x| buf[(y * w + x) * 4]).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_display_goes_offline_only_when_its_fallback_was_rejected() {
|
||||
// Unique name = unique health key; DRM_DISPLAY_HEALTH is process-wide.
|
||||
let list = vec![drm_display("TEST-lone-fallback", 1080, 1920)];
|
||||
let key = connector_key(&list[0]);
|
||||
let demoted = DisplayHealth {
|
||||
zero_frame_streak: DRM_GRAB_MAX_FAILURES,
|
||||
demotes: 1,
|
||||
..DisplayHealth::new()
|
||||
};
|
||||
// Demoted alone keeps the lone display online: the whole-desktop fallback is usable.
|
||||
DRM_DISPLAY_HEALTH.lock().unwrap().insert(key.clone(), demoted);
|
||||
let mut infos = vec![DisplayInfo {
|
||||
online: true,
|
||||
..Default::default()
|
||||
}];
|
||||
mark_demoted_displays(&list, &mut infos);
|
||||
assert!(infos[0].online, "the lone-display carve-out must survive");
|
||||
// A rejected fallback ends the carve-out: advertising online would restart-loop.
|
||||
DRM_DISPLAY_HEALTH
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut(&key)
|
||||
.expect("just inserted")
|
||||
.fallback_rejected = true;
|
||||
mark_demoted_displays(&list, &mut infos);
|
||||
assert!(!infos[0].online, "a rejected fallback must take the lone display offline");
|
||||
// Once the demotion cooldown lapses the display is no longer demoted, and online returns
|
||||
// even with the rejection still latched (the re-arm will clear it on the next build).
|
||||
DRM_DISPLAY_HEALTH
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut(&key)
|
||||
.expect("still there")
|
||||
.since = Instant::now() - demote_cooldown(1) - Duration::from_secs(1);
|
||||
infos[0].online = true;
|
||||
mark_demoted_displays(&list, &mut infos);
|
||||
assert!(infos[0].online, "past the cooldown the verdict is DRM's to retry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cursor_id_names_the_orientation_too() {
|
||||
// Same wire cursor under two transforms must publish as two ids, or the client's by-id
|
||||
// cache serves the previous orientation after a mid-session rotation.
|
||||
let wire = 0xDEAD_BEEF_u64;
|
||||
assert_ne!(fold_cursor_id(wire, 0), fold_cursor_id(wire, 90));
|
||||
assert_ne!(fold_cursor_id(wire, 90), fold_cursor_id(wire, 270));
|
||||
// Deterministic per (id, transform), so an unchanged cursor is still deduped.
|
||||
assert_eq!(fold_cursor_id(wire, 90), fold_cursor_id(wire, 90));
|
||||
// The hidden sentinel is compared by VALUE at the consumers, so it must pass unfolded.
|
||||
let hidden = scrap::drm_reader::HIDDEN_CURSOR_ID;
|
||||
assert_eq!(fold_cursor_id(hidden, 90), hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotate_hotspot_follows_the_pixel_mapping() {
|
||||
// 3 wide x 2 tall, hotspot at (2,0) (top-right): after the 90 turn (left column to top
|
||||
// row) that pixel sits at (1,2) in the 2x3 result; 270 sends it to (0,0).
|
||||
assert_eq!(unrotate_hotspot(90, 3, 2, 2, 0), (1, 2));
|
||||
assert_eq!(unrotate_hotspot(270, 3, 2, 2, 0), (0, 0));
|
||||
assert_eq!(unrotate_hotspot(180, 3, 2, 2, 0), (0, 1));
|
||||
assert_eq!(unrotate_hotspot(0, 3, 2, 2, 0), (2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_snapshot_generation_asks_for_a_rebuild_without_blaming_the_display() {
|
||||
let mut c = capturer_named(Some((64, 32)), Some("test:gen-rebuild"));
|
||||
c.snapshot_gen = c.snapshot_gen.wrapping_sub(1);
|
||||
put_frame(&c, 64, 32);
|
||||
let err = match c.frame(Duration::from_millis(50)) {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("a stale generation must rebuild, not deliver"),
|
||||
};
|
||||
assert!(err.to_string().contains("layout changed"), "{err}");
|
||||
assert!(!c.got_frame);
|
||||
assert_eq!(
|
||||
zero_frame_streak_of(&c),
|
||||
0,
|
||||
"a layout rebuild must not count against display health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotate_90_maps_the_left_column_to_the_top_row() {
|
||||
// The measured anchor from rustdesk#15886: mutter transform=1 carries the panel bar down
|
||||
// the scanout's LEFT edge, and upright means that edge becomes the TOP row.
|
||||
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
|
||||
let mut dst = Vec::new();
|
||||
unrotate_bgra(&src, w, h, 90, &mut dst);
|
||||
// src left column top-to-bottom = [1, 4]; clockwise puts it on the top row as [4, 1].
|
||||
assert_eq!(labels_of(&dst, h, w), vec![vec![4, 1], vec![5, 2], vec![6, 3]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotate_270_is_the_inverse_of_90() {
|
||||
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
|
||||
let mut once = Vec::new();
|
||||
unrotate_bgra(&src, w, h, 90, &mut once);
|
||||
let mut back = Vec::new();
|
||||
unrotate_bgra(&once, h, w, 270, &mut back);
|
||||
assert_eq!(back, src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotate_180_reverses_both_axes() {
|
||||
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
|
||||
let mut dst = Vec::new();
|
||||
unrotate_bgra(&src, w, h, 180, &mut dst);
|
||||
assert_eq!(labels_of(&dst, w, h), vec![vec![6, 5, 4], vec![3, 2, 1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotate_reads_padded_strides_and_writes_tight() {
|
||||
// Row stride is derived from len/h, so a padded source must not shear the result.
|
||||
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 8);
|
||||
let mut dst = Vec::new();
|
||||
unrotate_bgra(&src, w, h, 90, &mut dst);
|
||||
assert_eq!(dst.len(), w * h * 4);
|
||||
assert_eq!(labels_of(&dst, h, w), vec![vec![4, 1], vec![5, 2], vec![6, 3]]);
|
||||
let mut plain = Vec::new();
|
||||
unrotate_bgra(&src, w, h, 0, &mut plain);
|
||||
assert_eq!(labels_of(&plain, w, h), vec![vec![1, 2, 3], vec![4, 5, 6]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_session_delivers_rotated_frames_and_guards_in_rotated_dims() {
|
||||
use scrap::TraitPixelBuffer;
|
||||
let mut c = capturer_with(Some((32, 64))); // rotated session of a 64x32 scanout
|
||||
c.transform = 90;
|
||||
put_frame(&c, 64, 32);
|
||||
match c.frame(Duration::from_millis(50)) {
|
||||
Ok(Frame::PixelBuffer(pb)) => {
|
||||
assert_eq!((pb.width(), pb.height()), (32, 64));
|
||||
}
|
||||
Ok(_) => panic!("expected a pixel-buffer frame"),
|
||||
Err(err) => panic!("expected a delivered frame, got {err}"),
|
||||
}
|
||||
// A scanout change still ends the session, reported in rotated dimensions.
|
||||
put_frame(&c, 32, 64);
|
||||
let err = match c.frame(Duration::from_millis(50)) {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("a scanout change must end a rotated session too"),
|
||||
};
|
||||
assert!(err.to_string().contains("(32x64 -> 64x32)"), "{err}");
|
||||
}
|
||||
|
||||
fn zero_frame_streak_of(c: &IpcDrmCapturer) -> u32 {
|
||||
let key = c.connector.clone().expect("this check needs an identity");
|
||||
DRM_DISPLAY_HEALTH
|
||||
@@ -1525,6 +1994,7 @@ mod drm_capturer_tests {
|
||||
h.rapid_builds = 3;
|
||||
h.last_build = Some(Instant::now());
|
||||
h.prefer_cpu = true;
|
||||
h.fallback_rejected = true;
|
||||
}
|
||||
put_frame(&c, 64, 32);
|
||||
assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_)));
|
||||
@@ -1537,6 +2007,10 @@ mod drm_capturer_tests {
|
||||
};
|
||||
assert_eq!(h.zero_frame_streak, 0, "a delivered frame refutes the zero-frame streak");
|
||||
assert_eq!(h.demotes, 0, "and the demotion count that streak drove");
|
||||
assert!(
|
||||
!h.fallback_rejected,
|
||||
"a delivered frame also refutes the rejected-fallback verdict"
|
||||
);
|
||||
assert_eq!(
|
||||
h.rapid_builds, 3,
|
||||
"but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \
|
||||
@@ -1642,9 +2116,53 @@ mod drm_capturer_tests {
|
||||
height: h,
|
||||
logical_size: Some((w, h)),
|
||||
refresh_rate: 60,
|
||||
transform: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_rotated_output_advertises_delivered_dimensions() {
|
||||
// Fix for the origin-only cut: one connector, one rotated output. The capturer will
|
||||
// deliver rotated frames, so the advertised size must swap even in the origin-only case,
|
||||
// while the logical scale is still not adopted (stays 1.0).
|
||||
let drm = [drm_display("HDMI-A-1", 1920, 1080)];
|
||||
let mut out = wl_display("HDMI-1", 0, 0, 1920, 1080);
|
||||
out.transform = 90;
|
||||
let wl = scrap::wayland::display::Displays {
|
||||
primary: 0,
|
||||
displays: vec![out],
|
||||
};
|
||||
let assignment = assign_wayland_outputs(&drm, &wl.displays);
|
||||
let infos = augment_with_wayland_geometry_from(&drm, &wl, &assignment);
|
||||
assert_eq!((infos[0].width, infos[0].height), (1080, 1920));
|
||||
assert_eq!(infos[0].scale, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_and_origin_come_from_the_same_snapshot() {
|
||||
// Both derive from ONE Displays snapshot: the rotated output's transform and its origin
|
||||
// must belong to the same assignment, and the multi-connector one-output guard zeroes
|
||||
// both rather than mixing a guessed origin with a real transform.
|
||||
let drm = [
|
||||
drm_display("HDMI-A-1", 1920, 1080),
|
||||
drm_display("DP-1", 2560, 1440),
|
||||
];
|
||||
let mut rotated = wl_display("DP-1", 1920, 0, 2560, 1440);
|
||||
rotated.transform = 270;
|
||||
let wl = scrap::wayland::display::Displays {
|
||||
primary: 0,
|
||||
displays: vec![rotated, wl_display("HDMI-1", 0, 0, 1920, 1080)],
|
||||
};
|
||||
let (t, origin) = transform_and_origin(&drm, 1, &wl);
|
||||
assert_eq!(t, 270);
|
||||
assert_eq!(origin, Some((1920, 0)));
|
||||
let lone = scrap::wayland::display::Displays {
|
||||
primary: 0,
|
||||
displays: vec![wl_display("HDMI-1", 0, 0, 1920, 1080)],
|
||||
};
|
||||
assert_eq!(transform_and_origin(&drm, 1, &lone), (0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_connector_assignment_drives_geometry_and_primary() {
|
||||
let drm = [
|
||||
@@ -1725,6 +2243,32 @@ mod drm_capturer_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resolution_guess_never_steals_an_exact_name_match() {
|
||||
// The review's scenario: an earlier connector with an unmatchable name shares the
|
||||
// resolution of a later connector's exact name match. Names reserve globally first.
|
||||
let drm = vec![
|
||||
drm_display("DSI-1", 1920, 1080),
|
||||
drm_display("HDMI-A-1", 1920, 1080),
|
||||
];
|
||||
let wl = vec![
|
||||
wl_display("HDMI-1", 0, 0, 1920, 1080),
|
||||
wl_display("Unknown-9", 1920, 0, 2560, 1440),
|
||||
];
|
||||
let m = identity_matches(&drm, &wl);
|
||||
assert_eq!(m[1], Some(0), "the exact name match must win globally");
|
||||
assert_eq!(m[0], None, "the leftover pairing is not forced, so no identity");
|
||||
// Two unmatched connectors at the lone free resolution: ambiguous on the DRM side too,
|
||||
// so rotation must not be pinned on either.
|
||||
let drm2 = vec![
|
||||
drm_display("DSI-1", 1920, 1080),
|
||||
drm_display("DSI-2", 1920, 1080),
|
||||
];
|
||||
let wl2 = vec![wl_display("HDMI-1", 0, 0, 1920, 1080)];
|
||||
let m2 = identity_matches(&drm2, &wl2);
|
||||
assert!(m2[0].is_none() && m2[1].is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_are_matched_by_name_across_the_drm_naming_difference() {
|
||||
let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)];
|
||||
|
||||
@@ -108,7 +108,8 @@ struct CapDisplayInfo {
|
||||
}
|
||||
|
||||
/// Uinput desktop rect from the DRM display list, for a login screen where no compositor can be
|
||||
/// asked. `(minx, maxx, miny, maxy)`, in scanout pixels: no compositor here applied a scale, so
|
||||
/// asked. `(minx, maxx, miny, maxy)`, in delivered-orientation physical pixels (a rotated
|
||||
/// output counts transposed, matching its frames): no compositor here applied a scale, so
|
||||
/// unlike `desktop_rect_of` there is no logical size to handle.
|
||||
#[cfg(feature = "drm")]
|
||||
fn drm_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
@@ -521,11 +522,13 @@ pub(super) fn get_capturer_for_display(
|
||||
// (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`.
|
||||
// `try_fix_logical_size` only repairs the capturable's SEPARATE
|
||||
// `logical_size` field and never touches `physical_size`, so the rect is not
|
||||
// logical. The advertised DRM geometry is physical too
|
||||
// (`augment_with_wayland_geometry` sets x/y/scale and deliberately leaves
|
||||
// width/height as the DRM mode). Dividing one side by the scale therefore
|
||||
// compares logical against physical and rejects the valid stream on exactly
|
||||
// the scaled outputs it was meant to rescue.
|
||||
// logical. The advertised DRM geometry is physical too, in DELIVERED
|
||||
// orientation: `augment_with_wayland_geometry` transposes width/height for a
|
||||
// 90/270 output (rustdesk#15886). Whether the portal's caps arrive rotated
|
||||
// is UNMEASURED on a rotated display (pipewiresrc does not apply
|
||||
// SPA_META_VideoTransform), so the size half accepts either orientation
|
||||
// rather than gambling a permanent offline on one of them. Dividing a side
|
||||
// by the scale would still be wrong: logical against physical.
|
||||
//
|
||||
// The size check is what tells one connector apart from the whole-desktop
|
||||
// rect the portal usually exposes. It is skipped only when BOTH sides say
|
||||
@@ -537,15 +540,35 @@ pub(super) fn get_capturer_for_display(
|
||||
// a monitor on a card the service cannot open is missing from the DRM list
|
||||
// while the compositor still drives it.
|
||||
let single_display = single_display && cap_display_info.num == 1;
|
||||
// Exact orientation only: a transposed stream would be encoded at the
|
||||
// PipeWire dimensions while the client keeps the advertised (rotated) ones,
|
||||
// and no wayland path ever reconciles the two, so every frame would be
|
||||
// rejected client-side. Falling into the bail instead advertises the display
|
||||
// offline, which the client recovers from by re-enumerating.
|
||||
let size_matches = advertised.width as usize == rect.1
|
||||
&& advertised.height as usize == rect.2;
|
||||
let transposed = advertised.width as usize == rect.2
|
||||
&& advertised.height as usize == rect.1;
|
||||
// The single-display carve-out forgives a size DIFFERENCE (a Full Workspace
|
||||
// stream may report the workspace, not the mode), but never a transposed
|
||||
// pair: that is the same served-vs-advertised orientation split as above,
|
||||
// and it blanks the client the same way.
|
||||
let consistent = advertised.x == rect.0 .0
|
||||
&& advertised.y == rect.0 .1
|
||||
&& (single_display
|
||||
|| (advertised.width as usize == rect.1
|
||||
&& advertised.height as usize == rect.2));
|
||||
&& (size_matches || (single_display && !transposed));
|
||||
if !consistent {
|
||||
// Recorded so the lone-display carve-out in `mark_demoted_displays` makes
|
||||
// the "advertised offline" below true for a single display too, instead of
|
||||
// restart-looping against a stream nothing can serve.
|
||||
super::drm_capturer::mark_fallback_rejected(display_idx);
|
||||
bail!(
|
||||
"drm display {} demoted with no geometry-consistent PipeWire stream (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline",
|
||||
"drm display {} demoted with no geometry-consistent PipeWire stream{} (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline",
|
||||
display_idx,
|
||||
if transposed {
|
||||
" - stream is transposed vs advertised"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
advertised.width,
|
||||
advertised.height,
|
||||
advertised.x,
|
||||
|
||||
@@ -1294,7 +1294,13 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
|
||||
// override only if true
|
||||
if true == force_relay {
|
||||
self.lc.write().unwrap().force_relay = true;
|
||||
let mut lc = self.lc.write().unwrap();
|
||||
lc.force_relay = true;
|
||||
// An explicit retry-via-relay is a decision about this peer, not transport
|
||||
// necessity: Relay-only ICE for this round like any force-always-relay session,
|
||||
// and it is the one kind of relay that belongs in the peer's saved config.
|
||||
lc.policy_relay = true;
|
||||
lc.peer_relay = true;
|
||||
}
|
||||
self.lc.write().unwrap().peer_info = None;
|
||||
self.reconnect_count.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
Reference in New Issue
Block a user