mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-17 01:41:02 +03:00
Compare commits
88 Commits
webrtc-ans
...
fix-unzoom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f487aaed99 | ||
|
|
70e345a76d | ||
|
|
6a62b7bbd9 | ||
|
|
74c818bef9 | ||
|
|
8f499c2eb5 | ||
|
|
2d5d11282b | ||
|
|
f6c89aa06c | ||
|
|
c142cba450 | ||
|
|
ee8c843bc4 | ||
|
|
2cfbbd56b4 | ||
|
|
e7f307bb80 | ||
|
|
a0f24abe80 | ||
|
|
6137be5414 | ||
|
|
e76b7e7bc2 | ||
|
|
1982bb3abd | ||
|
|
8cf138e2cc | ||
|
|
19040f3eef | ||
|
|
c02f17212a | ||
|
|
fbfa37c8fd | ||
|
|
819535f1db | ||
|
|
110ddb9a6c | ||
|
|
9a1c1a1a36 | ||
|
|
3629db2aa0 | ||
|
|
8869a440da | ||
|
|
777407e72e | ||
|
|
20ede1a85f | ||
|
|
a87d9c1082 | ||
|
|
bb06f54070 | ||
|
|
16088cfc80 | ||
|
|
b630f14483 | ||
|
|
d2d7c83602 | ||
|
|
d2561f7f6b | ||
|
|
70c50402a2 | ||
|
|
bbc8ee3bdb | ||
|
|
32e9895294 | ||
|
|
feff308f11 | ||
|
|
5a89aa8c58 | ||
|
|
6e2d838bf1 | ||
|
|
1fabdd6a9c | ||
|
|
afdd1d5c9d | ||
|
|
371e5e8f2d | ||
|
|
4d064a8445 | ||
|
|
02e4f02815 | ||
|
|
bbf86259e9 | ||
|
|
8dfff101f8 | ||
|
|
6934b3cb14 | ||
|
|
8f3beba2d1 | ||
|
|
be1f4e0a33 | ||
|
|
cb068ba5df | ||
|
|
1d98d1a647 | ||
|
|
944f93ba7b | ||
|
|
aea8e2a4a6 | ||
|
|
d65faca21f | ||
|
|
85e7c72ccd | ||
|
|
a3bfe075d5 | ||
|
|
7e2e9b68da | ||
|
|
6871ae130e | ||
|
|
54881092b7 | ||
|
|
34ceb16b63 | ||
|
|
d95c3f2ec9 | ||
|
|
daafa09318 | ||
|
|
96cd5cd1e2 | ||
|
|
d876ea0ce4 | ||
|
|
2a61a0955a | ||
|
|
db7c48e1a7 | ||
|
|
e72e7fb8ba | ||
|
|
4f0f784532 | ||
|
|
844bf40703 | ||
|
|
f8bfc4eb56 | ||
|
|
1061670368 | ||
|
|
8da629c57d | ||
|
|
650a6e21cc | ||
|
|
5f4bb00007 | ||
|
|
a7f1eb4c25 | ||
|
|
be5fb304d4 | ||
|
|
fdd67a875b | ||
|
|
39940b717a | ||
|
|
67b94c7906 | ||
|
|
eeff9eb121 | ||
|
|
c983d00437 | ||
|
|
7bb3fe6b5a | ||
|
|
d202a2fba4 | ||
|
|
72ea38ca4f | ||
|
|
f96d00d9d1 | ||
|
|
f5b98b32f2 | ||
|
|
8aee2a442e | ||
|
|
12eaf2cc75 | ||
|
|
7194743a30 |
25
.github/workflows/flutter-build.yml
vendored
25
.github/workflows/flutter-build.yml
vendored
@@ -374,12 +374,6 @@ jobs:
|
||||
|
||||
- name: Add MSBuild to PATH
|
||||
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||
with:
|
||||
# Select the MSBuild process architecture; -p:Platform sets the MSI target.
|
||||
# Native ARM64 tools give the compiler more address space for PCH files
|
||||
# (C3859/C1076 were reported by HostX86\arm64\CL.exe).
|
||||
# Keep the action's default x86 MSBuild for the existing x64 MSI job.
|
||||
msbuild-architecture: ${{ matrix.job.arch == 'aarch64' && 'arm64' || 'x86' }}
|
||||
|
||||
- name: Build msi
|
||||
# Builds the MSI for the matrix arch. res/msi (WiX v4 + native CustomActions) carries
|
||||
@@ -498,20 +492,11 @@ jobs:
|
||||
version: ${{ env.LLVM_VERSION }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
shell: bash
|
||||
run: |
|
||||
# Sciter's abi_thiscall feature requires nightly Rust.
|
||||
# Use an i686 host toolchain so build scripts can load the 32-bit LLVM installed above.
|
||||
# Since rustup 1.29.1, i686 toolchains on x64 Windows require --force-non-host
|
||||
# for both installation and default selection, even though WOW64 can run them.
|
||||
# See https://github.com/rust-lang/rustup/pull/4935.
|
||||
# Alternatively, an x64 compiler could use cargo build --target i686-pc-windows-msvc.
|
||||
# This requires 64-bit LLVM for host build scripts and updated packaging paths,
|
||||
# and has not been manually verified for RustDesk.
|
||||
rustup toolchain install nightly-2023-10-13-${{ matrix.job.target }} \
|
||||
--target ${{ matrix.job.target }} --component rustfmt \
|
||||
--profile minimal --no-self-update --force-non-host
|
||||
rustup default nightly-2023-10-13-${{ matrix.job.target }} --force-non-host
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: nightly-2023-10-13-${{ matrix.job.target }} # must use nightly here, because of abi_thiscall feature required
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
|
||||
59
Cargo.lock
generated
59
Cargo.lock
generated
@@ -985,9 +985,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
dependencies = [
|
||||
"serde 1.0.228",
|
||||
]
|
||||
@@ -1717,7 +1717,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#96d4da121b7d949677ac5b6887413a9185fd7f39"
|
||||
source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#69ad2578adc9200093fc81cdfbdad63dbc4274f9"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"cidre",
|
||||
@@ -1791,9 +1791,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -4121,7 +4121,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "interceptor"
|
||||
version = "0.14.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac0781c825d602095113772e389ef0607afcb869ae0e68a590d8e0799cdcef8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -6495,9 +6496,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.15"
|
||||
version = "0.11.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.2",
|
||||
@@ -7007,7 +7008,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rtcp"
|
||||
version = "0.13.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9689528bf3a9eb311fd938d05516dd546412f9ce4fffc8acfc1db27cc3dbf72"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"thiserror 1.0.61",
|
||||
@@ -7017,7 +7019,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rtp"
|
||||
version = "0.13.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c54733451a67d76caf9caa07a7a2cec6871ea9dda92a7847f98063d459200f4b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
@@ -7136,7 +7139,6 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"libpulse-binding",
|
||||
"libpulse-simple-binding",
|
||||
"libsamplerate-sys",
|
||||
"libxdo-sys",
|
||||
"mac_address",
|
||||
"magnum-opus",
|
||||
@@ -7196,6 +7198,7 @@ dependencies = [
|
||||
"wol-rs",
|
||||
"x11-clipboard 0.8.1",
|
||||
"x11rb 0.12.0",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7462,7 +7465,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sdp"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd277015eada44a0bb810a4b84d3bf6e810573fa62fb442f457edf6a1087a69"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"substring",
|
||||
@@ -8034,7 +8038,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "stun"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dbc2bab375524093c143dc362a03fb6a1fb79e938391cdb21665688f88a088a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"crc",
|
||||
@@ -8916,7 +8921,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "turn"
|
||||
version = "0.10.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f5aea1116456e1da71c45586b87c72e3b43164fbf435eb93ff6aa475416a9a4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -9579,7 +9585,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc"
|
||||
version = "0.13.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24bab7195998d605c862772f90a452ba655b90a2f463c850ac032038890e367a"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9622,7 +9629,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-data"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e97b932854da633a767eff0cc805425a2222fc6481e96f463e57b015d949d1d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"log",
|
||||
@@ -9636,7 +9644,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-dtls"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ccbe4d9049390ab52695c3646c1395c877e16c15fb05d3bda8eee0c7351711c"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -9672,7 +9681,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-ice"
|
||||
version = "0.13.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb51bde0d790f109a15bfe4d04f1b56fb51d567da231643cb3f21bb74d678997"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9696,7 +9706,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-mdns"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "979cc85259c53b7b620803509d10d35e2546fa505d228850cbe3f08765ea6ea8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"socket2 0.5.10",
|
||||
@@ -9708,7 +9719,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-media"
|
||||
version = "0.10.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80041211deccda758a3e19aa93d6b10bc1d37c9183b519054b40a83691d13810"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -9720,7 +9732,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-sctp"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=db3b07a9dd8f195916c89c2e62a8911402b11d27#db3b07a9dd8f195916c89c2e62a8911402b11d27"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9737,7 +9749,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-srtp"
|
||||
version = "0.15.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01e773f79b09b057ffbda6b03fe7b43403b012a240cf8d05d630674c3723b5bb"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
@@ -9759,7 +9772,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-util"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=db3b07a9dd8f195916c89c2e62a8911402b11d27#db3b07a9dd8f195916c89c2e62a8911402b11d27"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags 1.3.2",
|
||||
|
||||
14
Cargo.toml
14
Cargo.toml
@@ -22,7 +22,7 @@ path = "src/service.rs"
|
||||
|
||||
[features]
|
||||
inline = []
|
||||
use_samplerate = ["samplerate", "libsamplerate-sys"]
|
||||
use_samplerate = ["samplerate"]
|
||||
use_rubato = ["rubato"]
|
||||
use_dasp = ["dasp"]
|
||||
flutter = ["flutter_rust_bridge"]
|
||||
@@ -40,7 +40,6 @@ drm-wake = ["drm"]
|
||||
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
|
||||
unix-file-copy-paste = [
|
||||
"dep:x11-clipboard",
|
||||
"dep:x11rb",
|
||||
"dep:percent-encoding",
|
||||
"dep:once_cell",
|
||||
"clipboard/unix-file-copy-paste",
|
||||
@@ -62,12 +61,12 @@ cfg-if = "1.0"
|
||||
lazy_static = "1.4"
|
||||
sha2 = "0.10"
|
||||
repng = "0.2"
|
||||
zstd = "0.13"
|
||||
parity-tokio-ipc = { git = "https://github.com/rustdesk-org/parity-tokio-ipc" }
|
||||
magnum-opus = { git = "https://github.com/rustdesk-org/magnum-opus" }
|
||||
dasp = { version = "0.11", features = ["signal", "interpolate-linear", "interpolate"], optional = true }
|
||||
rubato = { version = "0.12", optional = true }
|
||||
samplerate = { version = "0.2", optional = true }
|
||||
libsamplerate-sys = { version = "0.1.12", optional = true }
|
||||
uuid = { version = "1.3", features = ["v4"] }
|
||||
num_cpus = "1.15"
|
||||
bytes = { version = "1.4", features = ["serde"] }
|
||||
@@ -192,7 +191,7 @@ evdev = { git="https://github.com/rustdesk-org/evdev" }
|
||||
dbus = "0.9"
|
||||
dbus-crossroads = "0.5"
|
||||
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
||||
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
||||
x11rb = {version = "0.12", features = ["all-extensions"]}
|
||||
percent-encoding = {version = "2.3", optional = true}
|
||||
once_cell = {version = "1.18", optional = true}
|
||||
nix = { version = "0.29", features = ["term", "process"]}
|
||||
@@ -231,11 +230,8 @@ libxdo-sys = { path = "libs/libxdo-sys-stub" }
|
||||
# the SACK settle the rest (F-RTO), timed from the latest send, so a stall no longer resends the
|
||||
# whole backlog behind itself while a short lost tail still comes back at once.
|
||||
# Pinned by rev, not branch: a fork branch can be rewritten out from under the lockfile.
|
||||
# webrtc: SettingEngine cannot reach the ICE agent's max_binding_requests, which decides how
|
||||
# long the answerer keeps checking a pair that has not answered yet.
|
||||
webrtc = { git = "https://github.com/rustdesk-org/webrtc", rev = "80d5a20532cf58f5d4d237c437a98ceb85ee40dc" }
|
||||
webrtc-util = { git = "https://github.com/rustdesk-org/webrtc", rev = "80d5a20532cf58f5d4d237c437a98ceb85ee40dc" }
|
||||
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "80d5a20532cf58f5d4d237c437a98ceb85ee40dc" }
|
||||
webrtc-util = { git = "https://github.com/rustdesk-org/webrtc", rev = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
|
||||
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM debian:trixie-slim
|
||||
FROM debian:bullseye-slim
|
||||
|
||||
WORKDIR /
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
Applicazione open source per desktop remoto, l'alternativa open source a TeamViewer.
|
||||
Codice sorgente: https://github.com/rustdesk/rustdesk
|
||||
Documentazione: https://rustdesk.com/docs/it/client/android/
|
||||
|
||||
Per consentire a un dispositivo remoto di controllare il tuo Android tramite mouse o tocco, devi permettere a RustDesk di utilizzare il servizio "Accessibilità". RustDesk utilizza l'API AccessibilityService per implementare il controllo remoto su Android.
|
||||
|
||||
Oltre al controllo remoto, puoi anche trasferire facilmente file tra dispositivi Android e PC con RustDesk.
|
||||
|
||||
Hai il controllo totale dei tuoi dati, senza preoccupazioni per la sicurezza. Puoi usare il nostro server rendezvous/relay, optare per l'hosting autonomo, o scrivere il tuo server rendezvous/relay. Il server auto-ospitato è gratuito e open source: https://github.com/rustdesk/rustdesk-server
|
||||
|
||||
Scarica e installa la versione per desktop da: https://rustdesk.com — potrai quindi accedere e controllare il tuo computer dal cellulare, o controllare il tuo cellulare dal computer.
|
||||
@@ -1 +0,0 @@
|
||||
App open source per desktop remoto, alternativa a TeamViewer.
|
||||
@@ -51,7 +51,6 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
private var minBufferSize = 0
|
||||
private var audioRecordStat = false
|
||||
private var audioThread: Thread? = null
|
||||
private var playbackCapturePending = false
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun createAudioRecorder(inVoiceCall: Boolean, mediaProjection: MediaProjection?): Boolean {
|
||||
@@ -194,17 +193,6 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
||||
}
|
||||
|
||||
fun getVoiceCallStartError(): String {
|
||||
return if (ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
) != PackageManager.PERMISSION_GRANTED) {
|
||||
"To start a voice call, enable \"Audio capture\" on the \"Screen share\" page."
|
||||
} else {
|
||||
"Failed to start voice call."
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
|
||||
if (!isSupportVoiceCall()) {
|
||||
return false
|
||||
@@ -232,7 +220,6 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
if (it.getAudioSource() == MediaRecorder.AudioSource.VOICE_COMMUNICATION) {
|
||||
return true
|
||||
}
|
||||
playbackCapturePending = true
|
||||
}
|
||||
audioRecordStat = false
|
||||
audioThread?.join()
|
||||
@@ -247,10 +234,11 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun switchOutVoiceCall(mediaProjection: MediaProjection?): Boolean {
|
||||
if (!isVoiceCallActive() && !playbackCapturePending) {
|
||||
return true
|
||||
audioRecorder?.let {
|
||||
if (it.getAudioSource() != MediaRecorder.AudioSource.VOICE_COMMUNICATION) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
playbackCapturePending = true
|
||||
audioRecordStat = false
|
||||
audioThread?.join()
|
||||
|
||||
@@ -258,11 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
Log.e(logTag, "createAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
val started = startAudioRecorder()
|
||||
if (started) {
|
||||
playbackCapturePending = false
|
||||
}
|
||||
return started
|
||||
return startAudioRecorder()
|
||||
}
|
||||
|
||||
fun tryReleaseAudio() {
|
||||
@@ -272,7 +256,6 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
audioRecordStat = false
|
||||
audioThread?.join()
|
||||
audioThread = null
|
||||
playbackCapturePending = false
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
|
||||
@@ -969,7 +969,7 @@ class MainActivity : FlutterActivity() {
|
||||
flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
"title" to "Voice call",
|
||||
"text" to audioRecordHandle.getVoiceCallStartError()))
|
||||
"text" to "Failed to start voice call."))
|
||||
} else {
|
||||
Log.d(logTag, "onVoiceCallStarted success")
|
||||
}
|
||||
|
||||
@@ -153,13 +153,19 @@ class MainService : Service() {
|
||||
} else {
|
||||
if (!switchOutVoiceCall()) {
|
||||
Log.e(logTag, "switchOutVoiceCall fail")
|
||||
showVoiceCallError("Failed to switch out voice call.")
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
"title" to "Voice call",
|
||||
"text" to "Failed to switch out voice call."))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!switchToVoiceCall()) {
|
||||
Log.e(logTag, "switchToVoiceCall fail")
|
||||
showVoiceCallError(audioRecordHandle.getVoiceCallStartError())
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
"title" to "Voice call",
|
||||
"text" to "Failed to switch to voice call."))
|
||||
}
|
||||
}
|
||||
} catch (e: JSONException) {
|
||||
@@ -501,15 +507,6 @@ class MainService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showVoiceCallError(message: String) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
"title" to "Voice call",
|
||||
"text" to message))
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startMicrophoneCapture(startAudio: () -> Boolean): Boolean {
|
||||
if (!setMicrophoneForegroundService(true)) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.carriez.flutter_hbb
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjectionConfig
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -20,13 +19,7 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
ACT_REQUEST_MEDIA_PROJECTION -> {
|
||||
val mediaProjectionManager =
|
||||
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
mediaProjectionManager.createScreenCaptureIntent(
|
||||
MediaProjectionConfig.createConfigForDefaultDisplay()
|
||||
)
|
||||
} else {
|
||||
mediaProjectionManager.createScreenCaptureIntent()
|
||||
}
|
||||
val intent = mediaProjectionManager.createScreenCaptureIntent()
|
||||
startActivityForResult(intent, REQ_REQUEST_MEDIA_PROJECTION)
|
||||
}
|
||||
else -> finish()
|
||||
|
||||
@@ -178,7 +178,6 @@ const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
|
||||
const String kOptionAllowSyncClipboardBetweenSessions =
|
||||
"allow-sync-clipboard-between-sessions";
|
||||
const String kOptionEnableWebrtc = "enable-webrtc";
|
||||
const String kOptionRelayFallbackDelay = "relay-fallback-delay";
|
||||
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
||||
const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||
|
||||
@@ -591,7 +591,13 @@ class _GeneralState extends State<_General> {
|
||||
isServer: false,
|
||||
),
|
||||
],
|
||||
if (!incomingOnly) ...webrtcOptions(context),
|
||||
if (!incomingOnly)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable WebRTC P2P connection',
|
||||
kOptionEnableWebrtc,
|
||||
isServer: false,
|
||||
),
|
||||
if (!isWeb && !incomingOnly)
|
||||
Tooltip(
|
||||
message: translate('sync-clipboard-between-sessions-tip'),
|
||||
@@ -881,85 +887,6 @@ class _GeneralState extends State<_General> {
|
||||
).marginOnly(left: _kContentHMargin);
|
||||
});
|
||||
}
|
||||
|
||||
// How long an already-connected relay is held back to give the direct WebRTC
|
||||
// attempt a chance to win. It only means anything while WebRTC is on, so it
|
||||
// follows the checkbox as an indented sub-option and is hidden outright when
|
||||
// the box is clear — the shape `directIp` uses for its port.
|
||||
List<Widget> webrtcOptions(BuildContext context) {
|
||||
final stored = bind.mainGetLocalOption(key: kOptionRelayFallbackDelay);
|
||||
final controller = TextEditingController(text: stored);
|
||||
// What the field holds against what is saved. Apply is offered only while
|
||||
// the two differ, so an untouched field shows no button at all, and neither
|
||||
// does one typed back to its saved value or cleared when nothing was saved
|
||||
// — the state an "edited" flag alone would still call dirty.
|
||||
final typed = RxString(stored);
|
||||
final saved = RxString(stored);
|
||||
return [
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable WebRTC P2P connection',
|
||||
kOptionEnableWebrtc,
|
||||
isServer: false,
|
||||
update: (_) => setState(() {}),
|
||||
),
|
||||
() {
|
||||
final enabled = mainGetLocalBoolOptionSync(kOptionEnableWebrtc);
|
||||
final isOptFixed = isOptionFixed(kOptionRelayFallbackDelay);
|
||||
return Offstage(
|
||||
offstage: !enabled,
|
||||
child: Tooltip(
|
||||
message: translate('relay-fallback-delay-tip'),
|
||||
child: _SubLabeledWidget(
|
||||
context,
|
||||
'Relay fallback delay in seconds',
|
||||
Row(children: [
|
||||
SizedBox(
|
||||
width: 95,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
enabled: enabled && !isOptFixed,
|
||||
onChanged: (v) => typed.value = v,
|
||||
inputFormatters: [
|
||||
// Seconds, at most one decimal. Clearing the field is
|
||||
// allowed and restores the built-in default.
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^([0-9]|[1-9][0-9])(\.[0-9]?)?$')),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
hintText: '2.5',
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||
),
|
||||
).workaroundFreezeLinuxMint().marginOnly(right: 15),
|
||||
),
|
||||
Obx(() => Offstage(
|
||||
offstage: typed.value.trim() == saved.value.trim(),
|
||||
child: ElevatedButton(
|
||||
onPressed: enabled &&
|
||||
!isOptFixed &&
|
||||
!typed.value.trim().endsWith('.') &&
|
||||
double.tryParse(typed.value.trim()) != 0
|
||||
? () async {
|
||||
final v = controller.text.trim();
|
||||
await bind.mainSetLocalOption(
|
||||
key: kOptionRelayFallbackDelay, value: v);
|
||||
if (controller.text != v) controller.text = v;
|
||||
typed.value = v;
|
||||
saved.value = v;
|
||||
}
|
||||
: null,
|
||||
child: Text(translate('Apply')),
|
||||
),
|
||||
))
|
||||
]),
|
||||
enabled: enabled && !isOptFixed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
enum _AccessMode {
|
||||
|
||||
@@ -1101,22 +1101,44 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
final m = Provider.of<ImageModel>(context);
|
||||
var c = Provider.of<CanvasModel>(context);
|
||||
final s = c.scale;
|
||||
// CanvasModel caches the DPR and only refreshes it when the view style
|
||||
// changes, so read it live to follow the window across monitors.
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
|
||||
bool isViewAdaptive() => c.viewStyle.style == kRemoteViewStyleAdaptive;
|
||||
bool isViewScaled() =>
|
||||
c.viewStyle.style == kRemoteViewStyleAdaptive ||
|
||||
c.viewStyle.style == kRemoteViewStyleCustom;
|
||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
|
||||
mouseRegion({child}) => Obx(() {
|
||||
double getCursorScale() {
|
||||
var c = Provider.of<CanvasModel>(context);
|
||||
final cursor = Provider.of<CursorModel>(context);
|
||||
// Predefined artwork must not inherit the cached remote bitmap's DPI.
|
||||
final cache = keyboardEnabled.isTrue
|
||||
? cursor.cache ?? preDefaultCursor.cache
|
||||
: preForbiddenCursor.cache;
|
||||
final peerDpr = cache?.pixelRatio ?? 0;
|
||||
if (isViewScaled() && zoomCursor.isFalse && peerDpr > 0 &&
|
||||
(!isWeb || widget.ffi.ffiModel.pi.platform == kPeerPlatformMacOS)) {
|
||||
// Normalize to logical size; Windows takes physical pixels here.
|
||||
// Web normalizes only macOS's optional Retina image, preserving
|
||||
// the existing unzoomed sizing policy for other hosts.
|
||||
return (isWindows ? dpr : 1.0) / peerDpr;
|
||||
}
|
||||
// Without density, preserve the legacy unzoomed size. Controller
|
||||
// DPR alone cannot determine the remote bitmap's logical size.
|
||||
final imageScale = isViewScaled() && zoomCursor.isTrue
|
||||
? _cursorImageScale(widget.ffi, cursor, useLocalPointer: true)
|
||||
: s;
|
||||
var cursorScale = 1.0;
|
||||
if (isWindows) {
|
||||
// debug win10
|
||||
if (zoomCursor.value && isViewAdaptive()) {
|
||||
cursorScale = s * c.devicePixelRatio;
|
||||
if (zoomCursor.value && isViewScaled()) {
|
||||
cursorScale = imageScale * dpr;
|
||||
}
|
||||
} else {
|
||||
if (zoomCursor.value || isViewOriginal()) {
|
||||
cursorScale = s;
|
||||
cursorScale = imageScale;
|
||||
}
|
||||
}
|
||||
return cursorScale;
|
||||
@@ -1149,6 +1171,7 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
child: child);
|
||||
});
|
||||
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||
_syncScrollAfterLayout(c);
|
||||
final paintWidth = c.getDisplayWidth() * s;
|
||||
final paintHeight = c.getDisplayHeight() * s;
|
||||
final paintSize = Size(paintWidth, paintHeight);
|
||||
@@ -1193,6 +1216,20 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
}
|
||||
}
|
||||
|
||||
// Layout can change scroll metrics without notification. Compare against this
|
||||
// build's fractions: a delayed refresh may already have changed the model.
|
||||
void _syncScrollAfterLayout(CanvasModel canvas) {
|
||||
// Both supported scrollbar view styles also paint from scroll fractions.
|
||||
if (canvas.scrollStyle != ScrollStyle.scrolledge &&
|
||||
canvas.viewStyle.style != kRemoteViewStyleCustom &&
|
||||
canvas.viewStyle.style != kRemoteViewStyleOriginal) return;
|
||||
final renderedScroll = (canvas.scrollX, canvas.scrollY);
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
canvas.updateScrollAfterLayout(renderedScroll);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildScrollbarNonTextureRender(
|
||||
ImageModel m, Size imageSize, double s) {
|
||||
return CustomPaint(
|
||||
@@ -1384,43 +1421,119 @@ class CursorPaint extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
double cx = c.x;
|
||||
double cy = c.y;
|
||||
if (c.viewStyle.style == kRemoteViewStyleOriginal &&
|
||||
c.scrollStyle == ScrollStyle.scrollbar) {
|
||||
final imageOffset = _softwareImageOffset(c);
|
||||
double cx = imageOffset?.dx ?? c.x;
|
||||
double cy = imageOffset?.dy ?? c.y;
|
||||
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||
final rect = c.parent.target!.ffiModel.rect;
|
||||
if (rect == null) {
|
||||
// unreachable!
|
||||
debugPrint('unreachable! The displays rect is null.');
|
||||
return Container();
|
||||
}
|
||||
if (cx < 0) {
|
||||
final imageWidth = rect.width * c.scale;
|
||||
cx = -imageWidth * c.scrollX;
|
||||
}
|
||||
if (cy < 0) {
|
||||
final imageHeight = rect.height * c.scale;
|
||||
cy = -imageHeight * c.scrollY;
|
||||
}
|
||||
// Scrollbar and edge scrolling share a layout that ignores canvas pan offsets.
|
||||
final imageWidth = rect.width * c.scale;
|
||||
final imageHeight = rect.height * c.scale;
|
||||
// Match the video's integer centering on each fitting axis, even if a
|
||||
// detached scroll controller left a stale fraction before layout sync.
|
||||
cx = c.size.width < imageWidth
|
||||
? -imageWidth * c.scrollX
|
||||
: ((c.size.width - imageWidth) ~/ 2).toDouble();
|
||||
cy = c.size.height < imageHeight
|
||||
? -imageHeight * c.scrollY
|
||||
: ((c.size.height - imageHeight) ~/ 2).toDouble();
|
||||
}
|
||||
|
||||
double x = (m.x - hotx) * c.scale + cx;
|
||||
double y = (m.y - hoty) * c.scale + cy;
|
||||
double scale = 1.0;
|
||||
final isViewOriginal = c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
if (zoomCursor.value || isViewOriginal) {
|
||||
x = m.x - hotx + cx / c.scale;
|
||||
y = m.y - hoty + cy / c.scale;
|
||||
scale = c.scale;
|
||||
final image = m.image ?? preDefaultCursor.image;
|
||||
// Match native registration's logical minimum for density-aware scaled views.
|
||||
final logicalMinimum = (m.cache?.pixelRatio ?? 0) > 0 &&
|
||||
c.viewStyle.style != kRemoteViewStyleOriginal;
|
||||
final nativePixels = isWindows && !logicalMinimum
|
||||
? MediaQuery.devicePixelRatioOf(context) : 1.0;
|
||||
// Show remote cursor follows the image scale, independently of Zoom cursor.
|
||||
double scale = _cursorImageScale(c.parent.target!, m);
|
||||
if (image != null && (logicalMinimum || scale * nativePixels != 1.0)) {
|
||||
final sx = kMinCursorSize / (image.width * nativePixels);
|
||||
final sy = kMinCursorSize / (image.height * nativePixels);
|
||||
// Match native resizing: a thin axis must not enlarge the whole cursor.
|
||||
final minimumScale = sx < sy ? sx : sy;
|
||||
if (scale < minimumScale) scale = minimumScale;
|
||||
}
|
||||
// Anchor the hotspot to the video position even when the minimum size
|
||||
// makes the artwork scale differ from the video scale.
|
||||
final x = (m.x * c.scale + cx) / scale - hotx;
|
||||
final y = (m.y * c.scale + cy) / scale - hoty;
|
||||
|
||||
return CustomPaint(
|
||||
painter: ImagePainter(
|
||||
image: m.image ?? preDefaultCursor.image,
|
||||
image: image,
|
||||
x: x,
|
||||
y: y,
|
||||
scale: scale,
|
||||
useIntegerPosition: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Match video-origin rounding: source pixels for software, logical pixels
|
||||
/// for Linux textures. The caller handles scrolling and unrounded origins.
|
||||
/// Keep cursor positions and hotspots fractional.
|
||||
Offset? _softwareImageOffset(CanvasModel canvas) {
|
||||
if (canvas.imageOverflow.isTrue &&
|
||||
canvas.scrollStyle != ScrollStyle.scrollauto) {
|
||||
return null;
|
||||
}
|
||||
final ffi = canvas.parent.target!;
|
||||
final peer = ffi.ffiModel;
|
||||
if (ffi.imageModel.useTextureRender || peer.pi.forceTextureRender) {
|
||||
// Match Linux's texture origin without rounding the cursor or hotspot.
|
||||
return isLinux
|
||||
? Offset(canvas.x.toInt().toDouble(), canvas.y.toInt().toDouble())
|
||||
: null;
|
||||
}
|
||||
var scale = canvas.scale;
|
||||
final displays = peer.pi.getCurDisplays();
|
||||
if (peer.isPeerLinux && displays.isNotEmpty) scale /= displays[0].scale;
|
||||
// Match the origin used by _buildScrollAutoNonTextureRender's ImagePainter.
|
||||
return Offset(
|
||||
(canvas.x / scale).toInt() * scale, (canvas.y / scale).toInt() * scale);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Logical pixels per cursor bitmap pixel, matching the video renderer.
|
||||
/// Linux display geometry, not cursor density, determines this ratio.
|
||||
/// Native mixed-display cursors use mapped local input; overlays use the host
|
||||
/// position. Callers apply minimum size, controller DPR and hotspot offsets.
|
||||
double _cursorImageScale(FFI ffi, CursorModel cursor,
|
||||
{bool useLocalPointer = false}) {
|
||||
final canvas = ffi.canvasModel;
|
||||
final peer = ffi.ffiModel;
|
||||
if (!peer.isPeerLinux) return canvas.scale;
|
||||
if (canvas.imageOverflow.isTrue &&
|
||||
canvas.scrollStyle != ScrollStyle.scrollauto &&
|
||||
!ffi.imageModel.useTextureRender &&
|
||||
!peer.pi.forceTextureRender) {
|
||||
return canvas.scale; // The nontexture scrollbar also paints physical pixels.
|
||||
}
|
||||
final displays = peer.pi.getCurDisplays();
|
||||
if (displays.length == 1) return canvas.scale / displays.first.scale;
|
||||
final rect = peer.rect;
|
||||
if (rect != null) {
|
||||
// Mapped local input is already in host desktop coordinates. CursorModel's
|
||||
// position is relative to rect, so restore its origin for display lookup.
|
||||
// Before the first local movement, both paths use the host position.
|
||||
final position =
|
||||
(useLocalPointer ? ffi.inputModel.remotePointerPosition.value : null) ??
|
||||
Offset(cursor.x + rect.left, cursor.y + rect.top);
|
||||
for (final display in displays) {
|
||||
// Display origins are desktop coordinates; dimensions are physical pixels.
|
||||
// Convert the dimensions so these bounds use the same units as position.
|
||||
if (Rect.fromLTWH(display.x, display.y, display.width / display.scale,
|
||||
display.height / display.scale).contains(position)) {
|
||||
return canvas.scale / display.scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
return canvas.scale;
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
// `on_voice_call_closed` should be called when the connection is ended.
|
||||
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
|
||||
// Only one client is considered here for now.
|
||||
gFFI.chatModel.onVoiceCallClosed("End connection");
|
||||
gFFI.chatModel.onVoiceCallClosed("End connetion");
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -141,7 +141,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
// `on_voice_call_closed` should be called when the connection is ended.
|
||||
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
|
||||
// Only one client is considered here for now.
|
||||
gFFI.chatModel.onVoiceCallClosed("End connection");
|
||||
gFFI.chatModel.onVoiceCallClosed("End connetion");
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -449,6 +449,11 @@ class InputModel {
|
||||
final isPhysicalMouse = false.obs;
|
||||
int _lastButtons = 0;
|
||||
Offset lastMousePos = Offset.zero;
|
||||
// Latest local pointer position mapped to absolute remote desktop coordinates.
|
||||
// Used to select the native cursor's display scale in Linux All Displays mode,
|
||||
// without waiting for a host position update. Keep it separate from the
|
||||
// host-reported position used to paint the remote cursor.
|
||||
final remotePointerPosition = Rxn<Offset>();
|
||||
int _lastWheelTsUs = 0;
|
||||
|
||||
// Wheel acceleration thresholds.
|
||||
@@ -1954,7 +1959,7 @@ class InputModel {
|
||||
canvasModel.updateLocalCursor(x, y);
|
||||
}
|
||||
|
||||
return _handlePointerDevicePos(
|
||||
final point = _handlePointerDevicePos(
|
||||
kind,
|
||||
x,
|
||||
y,
|
||||
@@ -1965,6 +1970,20 @@ class InputModel {
|
||||
onExit: onExit,
|
||||
buttons: buttons,
|
||||
);
|
||||
_rememberRemotePointer(point, isMove);
|
||||
return point;
|
||||
}
|
||||
|
||||
void _rememberRemotePointer(Point? point, bool isMove) {
|
||||
final peer = parent.target!.ffiModel;
|
||||
if (point == null ||
|
||||
!isMove ||
|
||||
!(isDesktop || isWebDesktop) ||
|
||||
peer.pi.currentDisplay != kAllDisplayValue ||
|
||||
!peer.isPeerLinux) {
|
||||
return;
|
||||
}
|
||||
remotePointerPosition.value = Offset(point.x.toDouble(), point.y.toDouble());
|
||||
}
|
||||
|
||||
bool _isInCurrentWindow(double x, double y) {
|
||||
|
||||
@@ -2696,10 +2696,20 @@ class CanvasModel with ChangeNotifier {
|
||||
|
||||
setScrollPercent(scrollPixelPercent.x, scrollPixelPercent.y);
|
||||
pushScrollPositionToUI(scrollPixel.x, scrollPixel.y);
|
||||
// A no-op jump emits no notification to refresh the actual scroll fractions.
|
||||
updateScrollPercent();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateScrollAfterLayout((double, double) renderedScroll) {
|
||||
updateScrollPercent();
|
||||
// A delayed refresh may already have changed the model without repainting.
|
||||
if (renderedScroll != (_scrollX, _scrollY)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
panX(double dx) {
|
||||
_x += dx;
|
||||
if (isMobile) {
|
||||
@@ -2850,11 +2860,27 @@ class CanvasModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// data for cursor
|
||||
// Bound integer cache keys and raster allocation: 4096 squared RGBA is 64 MiB.
|
||||
const _maxCursorRasterSize = 4096;
|
||||
|
||||
bool _validCursorRasterSize(double width, double height,
|
||||
{double rasterScale = 1}) =>
|
||||
width.isFinite && height.isFinite && rasterScale.isFinite &&
|
||||
width > 0 && height > 0 && rasterScale > 0 &&
|
||||
width.ceilToDouble() * rasterScale <= _maxCursorRasterSize &&
|
||||
height.ceilToDouble() * rasterScale <= _maxCursorRasterSize;
|
||||
|
||||
// Scale the host's bitmap and hotspot together. Incorrect source geometry
|
||||
// must be fixed in host capture, independently of the client's sizing policy.
|
||||
class CursorData {
|
||||
final String peerId;
|
||||
final String id;
|
||||
final img2.Image image;
|
||||
// Borrowed from CursorModel/PredefinedCursor, which own its lifetime.
|
||||
// The plugin clones the handle before starting asynchronous encoding.
|
||||
final ui.Image nativeImage;
|
||||
// Zero preserves legacy sizing for capture backends without density metadata.
|
||||
final double pixelRatio;
|
||||
double scale;
|
||||
Uint8List? data;
|
||||
final double hotxOrigin;
|
||||
@@ -2868,6 +2894,8 @@ class CursorData {
|
||||
required this.peerId,
|
||||
required this.id,
|
||||
required this.image,
|
||||
required this.nativeImage,
|
||||
this.pixelRatio = 0,
|
||||
required this.scale,
|
||||
required this.data,
|
||||
required this.hotxOrigin,
|
||||
@@ -2879,20 +2907,35 @@ class CursorData {
|
||||
|
||||
int _doubleToInt(double v) => (v * 10e6).round().toInt();
|
||||
|
||||
double _checkUpdateScale(double scale) {
|
||||
double oldScale = this.scale;
|
||||
if (scale != 1.0) {
|
||||
// Update data if scale changed.
|
||||
final tgtWidth = (width * scale).toInt();
|
||||
final tgtHeight = (width * scale).toInt();
|
||||
if (tgtWidth < kMinCursorSize || tgtHeight < kMinCursorSize) {
|
||||
double sw = kMinCursorSize.toDouble() / width;
|
||||
double sh = kMinCursorSize.toDouble() / height;
|
||||
scale = sw < sh ? sh : sw;
|
||||
}
|
||||
// Keep the minimum-size policy here. Native callers let the plugin rasterize
|
||||
// the original ui.Image; Web keeps the encoded-image resizing path.
|
||||
double? _validatedScale(double scale,
|
||||
{required bool useLegacyMinimum, required double rasterScale}) {
|
||||
if (!scale.isFinite || scale <= 0) {
|
||||
debugPrint('Rejected cursor $id: invalid scale $scale');
|
||||
return null;
|
||||
}
|
||||
// Preserve unscaled legacy artwork, but never enlarge a thin cursor just
|
||||
// to make its short edge reach the visibility minimum.
|
||||
if (!useLegacyMinimum || scale != 1.0) {
|
||||
scale = max(scale, kMinCursorSize / max(width, height));
|
||||
}
|
||||
|
||||
if (_doubleToInt(oldScale) != _doubleToInt(scale)) {
|
||||
if (!_validCursorRasterSize(width * scale, height * scale,
|
||||
rasterScale: rasterScale)) {
|
||||
debugPrint('Rejected cursor $id: raster ${width * scale}x${height * scale} '
|
||||
'at pixel ratio $rasterScale exceeds $_maxCursorRasterSize');
|
||||
return null;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
double _checkUpdateScale(double scale, {required bool resizeImage}) {
|
||||
double oldScale = this.scale;
|
||||
// Web's long-edge minimum can round a thin axis below one raster pixel.
|
||||
final webWidth = max(1, (width * scale).round());
|
||||
final webHeight = max(1, (height * scale).round());
|
||||
if (resizeImage && _doubleToInt(oldScale) != _doubleToInt(scale)) {
|
||||
if (isWindows) {
|
||||
data = img2
|
||||
.copyResize(
|
||||
@@ -2907,8 +2950,8 @@ class CursorData {
|
||||
img2.encodePng(
|
||||
img2.copyResize(
|
||||
image,
|
||||
width: (width * scale).toInt(),
|
||||
height: (height * scale).toInt(),
|
||||
width: isWeb ? webWidth : (width * scale).toInt(),
|
||||
height: isWeb ? webHeight : (height * scale).toInt(),
|
||||
interpolation: img2.Interpolation.average,
|
||||
),
|
||||
),
|
||||
@@ -2919,12 +2962,22 @@ class CursorData {
|
||||
this.scale = scale;
|
||||
hotx = hotxOrigin * scale;
|
||||
hoty = hotyOrigin * scale;
|
||||
if (isWeb) {
|
||||
// CSS hotspots must follow the actual rounded PNG dimensions.
|
||||
hotx = hotxOrigin * webWidth / width;
|
||||
hoty = hotyOrigin * webHeight / height;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
String updateGetKey(double scale) {
|
||||
scale = _checkUpdateScale(scale);
|
||||
return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}';
|
||||
String? updateGetKey(double scale,
|
||||
{bool resizeImage = true, bool useLegacyMinimum = true,
|
||||
double rasterScale = 1}) {
|
||||
final effectiveScale = _validatedScale(scale,
|
||||
useLegacyMinimum: useLegacyMinimum, rasterScale: rasterScale);
|
||||
if (effectiveScale == null) return null;
|
||||
_checkUpdateScale(effectiveScale, resizeImage: resizeImage);
|
||||
return '${peerId}_${id}_${_doubleToInt(width * effectiveScale)}_${_doubleToInt(height * effectiveScale)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2964,7 +3017,8 @@ class PredefinedCursor {
|
||||
CursorData? get cache => _cache;
|
||||
|
||||
init() {
|
||||
_image2 = img2.decodePng(base64Decode(png));
|
||||
final pngBytes = base64Decode(png);
|
||||
_image2 = img2.decodePng(pngBytes);
|
||||
if (_image2 != null) {
|
||||
// The png type of forbidden cursor image is `PngColorType.indexed`.
|
||||
if (id == kPreForbiddenCursorId) {
|
||||
@@ -2972,17 +3026,20 @@ class PredefinedCursor {
|
||||
}
|
||||
|
||||
() async {
|
||||
final defaultImg = _image2!;
|
||||
// This function is called only one time, no need to care about the performance.
|
||||
Uint8List data = defaultImg.getBytes(order: img2.ChannelOrder.rgba);
|
||||
_image?.dispose();
|
||||
_image = await img.decodeImageFromPixels(
|
||||
data, defaultImg.width, defaultImg.height, ui.PixelFormat.rgba8888);
|
||||
if (_image == null) {
|
||||
print("decodeImageFromPixels failed, pre-defined cursor $id");
|
||||
return;
|
||||
// Native registration uses this ui.Image. The RGBA bytes from img2 are
|
||||
// straight alpha, but PixelFormat.rgba8888 requires premultiplied alpha.
|
||||
// Decode the PNG directly to preserve translucent cursor colors.
|
||||
final codec = await ui.instantiateImageCodec(pngBytes);
|
||||
final ui.Image nativeImage;
|
||||
try {
|
||||
nativeImage = (await codec.getNextFrame()).image;
|
||||
} finally {
|
||||
codec.dispose();
|
||||
}
|
||||
_image = nativeImage;
|
||||
double scale = 1.0;
|
||||
final Uint8List data;
|
||||
if (isWindows) {
|
||||
data = _image2!.getBytes(order: img2.ChannelOrder.bgra);
|
||||
} else {
|
||||
@@ -2993,6 +3050,7 @@ class PredefinedCursor {
|
||||
peerId: '',
|
||||
id: id,
|
||||
image: _image2!.clone(),
|
||||
nativeImage: nativeImage,
|
||||
scale: scale,
|
||||
data: data,
|
||||
hotxOrigin:
|
||||
@@ -3428,16 +3486,44 @@ class CursorModel with ChangeNotifier {
|
||||
final hoty = double.parse(evt['hoty']);
|
||||
final width = int.parse(evt['width']);
|
||||
final height = int.parse(evt['height']);
|
||||
// Rust validates native packets; Web receives them directly from JavaScript.
|
||||
if (isWeb && !_validCursorRasterSize(width.toDouble(), height.toDouble())) {
|
||||
debugPrint('Rejected cursor $id: invalid source size ${width}x$height');
|
||||
return;
|
||||
}
|
||||
final pixelRatio = double.tryParse(evt['scale'] ?? '0');
|
||||
if (pixelRatio == null ||
|
||||
(isWeb && (!pixelRatio.isFinite || pixelRatio < 0 ||
|
||||
(pixelRatio > 0 &&
|
||||
!_validCursorRasterSize(width / pixelRatio, height / pixelRatio))))) {
|
||||
debugPrint('Rejected cursor $id: invalid pixel ratio ${evt['scale']}');
|
||||
return;
|
||||
}
|
||||
List<dynamic> colors = json.decode(evt['colors']);
|
||||
const bytesPerPixel = 4;
|
||||
if (isWeb && colors.length != width * height * bytesPerPixel) {
|
||||
debugPrint('Rejected cursor $id: invalid RGBA length ${colors.length}');
|
||||
return;
|
||||
}
|
||||
final rgba = Uint8List.fromList(colors.map((s) => s as int).toList());
|
||||
final image = await img.decodeImageFromPixels(
|
||||
rgba, width, height, ui.PixelFormat.rgba8888);
|
||||
final ui.Image? image;
|
||||
final platform = parent.target?.ffiModel.pi.platform;
|
||||
if (!isWeb &&
|
||||
(platform == kPeerPlatformMacOS || platform == kPeerPlatformWindows)) {
|
||||
image = await _decodeStraightAlphaCursor(rgba, width, height);
|
||||
} else {
|
||||
image = await img.decodeImageFromPixels(
|
||||
rgba, width, height, ui.PixelFormat.rgba8888);
|
||||
}
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
if (await _updateCache(rgba, image, id, hotx, hoty, width, height)) {
|
||||
if (await _updateCache(rgba, image, id, hotx, hoty, width, height,
|
||||
pixelRatio: pixelRatio)) {
|
||||
_images[id]?.item1.dispose();
|
||||
_images[id] = Tuple3(image, hotx, hoty);
|
||||
} else {
|
||||
image.dispose();
|
||||
}
|
||||
|
||||
// Update last cursor data.
|
||||
@@ -3445,6 +3531,22 @@ class CursorModel with ChangeNotifier {
|
||||
_updateCurData();
|
||||
}
|
||||
|
||||
Future<ui.Image?> _decodeStraightAlphaCursor(
|
||||
Uint8List rgba, int width, int height) async {
|
||||
// macOS and Win32 capture send straight alpha; XFixes/DRM are premultiplied.
|
||||
// Convert a copy for native ui.Image, preserving the wire and PNG cache colors.
|
||||
final source = img2.Image.fromBytes(
|
||||
width: width, height: height, bytes: rgba.buffer, order: img2.ChannelOrder.rgba);
|
||||
for (final pixel in source) {
|
||||
final opacity = pixel.a / pixel.maxChannelValue;
|
||||
pixel.r = (pixel.r * opacity).round();
|
||||
pixel.g = (pixel.g * opacity).round();
|
||||
pixel.b = (pixel.b * opacity).round();
|
||||
}
|
||||
return img.decodeImageFromPixels(
|
||||
source.getBytes(), width, height, ui.PixelFormat.rgba8888);
|
||||
}
|
||||
|
||||
Future<bool> _updateCache(
|
||||
Uint8List rgba,
|
||||
ui.Image image,
|
||||
@@ -3452,8 +3554,9 @@ class CursorModel with ChangeNotifier {
|
||||
double hotx,
|
||||
double hoty,
|
||||
int w,
|
||||
int h,
|
||||
) async {
|
||||
int h, {
|
||||
required double pixelRatio,
|
||||
}) async {
|
||||
Uint8List? data;
|
||||
img2.Image imgOrigin = img2.Image.fromBytes(
|
||||
width: w, height: h, bytes: rgba.buffer, order: img2.ChannelOrder.rgba);
|
||||
@@ -3463,6 +3566,7 @@ class CursorModel with ChangeNotifier {
|
||||
ByteData? imgBytes =
|
||||
await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (imgBytes == null) {
|
||||
debugPrint('Unable to encode cursor $id as PNG');
|
||||
return false;
|
||||
}
|
||||
data = imgBytes.buffer.asUint8List();
|
||||
@@ -3471,6 +3575,8 @@ class CursorModel with ChangeNotifier {
|
||||
peerId: peerId,
|
||||
id: id,
|
||||
image: imgOrigin,
|
||||
nativeImage: image,
|
||||
pixelRatio: pixelRatio,
|
||||
scale: 1.0,
|
||||
data: data,
|
||||
hotxOrigin: hotx,
|
||||
@@ -3601,11 +3707,9 @@ class QualityMonitorModel with ChangeNotifier {
|
||||
bool get show => _show;
|
||||
QualityMonitorData get data => _data;
|
||||
|
||||
// Only a WebRTC session on the web names its transport here: web has no
|
||||
// session tab to show it on (the desktop tab's tooltip already does), and
|
||||
// WebRTC is the one path that can be direct or TURN.
|
||||
// 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 {
|
||||
if (!isWeb) return null;
|
||||
final ffiModel = parent.target?.ffiModel;
|
||||
if (ffiModel == null) return null;
|
||||
final streamType = ffiModel.cachedPeerData.streamType;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_custom_cursor/cursor_manager.dart'
|
||||
as custom_cursor_manager;
|
||||
import 'package:flutter_custom_cursor/flutter_custom_cursor.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show WidgetsBinding;
|
||||
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
|
||||
deleteCustomCursor(String key) =>
|
||||
@@ -16,29 +22,49 @@ MouseCursor buildCursorOfCache(
|
||||
if (cache == null) {
|
||||
return MouseCursor.defer;
|
||||
} else {
|
||||
final key = cache.updateGetKey(scale);
|
||||
// Include the live DPR so moving between monitors rebuilds the native
|
||||
// bitmap even when the remote view scale has not changed.
|
||||
final dpr = WidgetsBinding
|
||||
.instance.platformDispatcher.views.single.devicePixelRatio;
|
||||
// Keep Original and older peers' pixel units and scale-1 behavior.
|
||||
// Resizing uses a long-edge minimum regardless of source density.
|
||||
final legacyMinimum = cache.pixelRatio == 0 ||
|
||||
cursor.parent.target?.canvasModel.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
// The minimum is logical, while Windows callers pass a physical scale.
|
||||
final effectiveScale = !legacyMinimum && isWindows
|
||||
? math.max(scale, kMinCursorSize * dpr / math.max(cache.width, cache.height))
|
||||
: scale;
|
||||
final cacheKey = cache.updateGetKey(effectiveScale, resizeImage: false,
|
||||
useLegacyMinimum: legacyMinimum,
|
||||
rasterScale: isWindows ? 1 : (isLinux ? dpr.ceilToDouble() : dpr));
|
||||
if (cacheKey == null) return MouseCursor.defer;
|
||||
final key = '${cacheKey}_$dpr';
|
||||
if (!cursor.cachedKeys.contains(key)) {
|
||||
// data should be checked here, because it may be changed after `updateGetKey()`
|
||||
final data = cache.data;
|
||||
if (data == null) {
|
||||
return MouseCursor.defer;
|
||||
}
|
||||
debugPrint(
|
||||
"Register custom cursor with key $key (${cache.hotx},${cache.hoty})");
|
||||
// [Safety]
|
||||
// It's ok to call async registerCursor in current synchronous context,
|
||||
// because activating the cursor is also an async call and will always
|
||||
// be executed after this.
|
||||
custom_cursor_manager.CursorManager.instance
|
||||
.registerCursor(custom_cursor_manager.CursorData()
|
||||
..name = key
|
||||
..buffer = data
|
||||
..width = (cache.width * cache.scale).toInt()
|
||||
..height = (cache.height * cache.scale).toInt()
|
||||
..hotX = cache.hotx
|
||||
..hotY = cache.hoty);
|
||||
unawaited(custom_cursor_manager.CursorManager.instance
|
||||
.registerCursorImage(
|
||||
name: key,
|
||||
image: cache.nativeImage,
|
||||
hotSpot: Offset(cache.hotxOrigin, cache.hotyOrigin),
|
||||
// Windows callers already express scale in physical pixels.
|
||||
// The plugin takes logical scale and applies DPR during rasterization.
|
||||
scale: isWindows ? cache.scale / dpr : cache.scale,
|
||||
devicePixelRatio: dpr,
|
||||
)
|
||||
.then<void>((_) {}, onError: (Object error, StackTrace stack) {
|
||||
cursor.cachedKeys.remove(key);
|
||||
FlutterError.reportError(FlutterErrorDetails(
|
||||
exception: error,
|
||||
stack: stack,
|
||||
library: 'native cursor',
|
||||
context: ErrorDescription('registering cursor $key')));
|
||||
}));
|
||||
cursor.addKey(key);
|
||||
}
|
||||
return FlutterCustomMemoryImageCursor(key: key);
|
||||
return FlutterCustomMemoryImageCursor(
|
||||
key: key,
|
||||
registrationToken: custom_cursor_manager.CursorManager.instance
|
||||
.registrationTokenFor(key));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,12 +96,14 @@ class ImagePainter extends CustomPainter {
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.scale,
|
||||
this.useIntegerPosition = true,
|
||||
});
|
||||
|
||||
ui.Image? image;
|
||||
double x;
|
||||
double y;
|
||||
double scale;
|
||||
final bool useIntegerPosition;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
@@ -122,8 +124,10 @@ class ImagePainter extends CustomPainter {
|
||||
if (isWeb) {
|
||||
paint.filterQuality = FilterQuality.high;
|
||||
}
|
||||
canvas.drawImage(
|
||||
image!, Offset(x.toInt().toDouble(), y.toInt().toDouble()), paint);
|
||||
final position = useIntegerPosition
|
||||
? Offset(x.toInt().toDouble(), y.toInt().toDouble())
|
||||
: Offset(x, y);
|
||||
canvas.drawImage(image!, position, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:js' as js;
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -52,8 +53,9 @@ class CursorManager {
|
||||
'cursor',
|
||||
jsonEncode({
|
||||
'url': cursorData.url,
|
||||
'hotx': cursorData.hotX.toInt(),
|
||||
'hoty': cursorData.hotY.toInt(),
|
||||
// Rounding must keep the hotspot inside even a one-pixel raster.
|
||||
'hotx': cursorData.hotX.round().clamp(0, cursorData.width - 1),
|
||||
'hoty': cursorData.hotY.round().clamp(0, cursorData.height - 1),
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -104,7 +106,10 @@ MouseCursor buildCursorOfCache(
|
||||
if (cache == null) {
|
||||
return MouseCursor.defer;
|
||||
} else {
|
||||
final key = cache.updateGetKey(scale);
|
||||
// A short-edge minimum can enlarge thin artwork beyond CSS cursor limits.
|
||||
// Keep unzoomed images unchanged and use the long edge when resizing.
|
||||
final key = cache.updateGetKey(scale, useLegacyMinimum: scale == 1.0);
|
||||
if (key == null) return MouseCursor.defer;
|
||||
if (!cursor.cachedKeys.contains(key)) {
|
||||
// data should be checked here, because it may be changed after `updateGetKey()`
|
||||
final data = cache.data;
|
||||
@@ -116,8 +121,8 @@ MouseCursor buildCursorOfCache(
|
||||
CursorManager.instance.registerCursor(CursorData(
|
||||
key: key,
|
||||
url: 'data:image/rgba;base64,${base64Encode(data)}',
|
||||
width: (cache.width * cache.scale).toInt(),
|
||||
height: (cache.height * cache.scale).toInt(),
|
||||
width: max(1, (cache.width * cache.scale).round()),
|
||||
height: max(1, (cache.height * cache.scale).round()),
|
||||
hotX: cache.hotx,
|
||||
hotY: cache.hoty));
|
||||
cursor.addKey(key);
|
||||
|
||||
@@ -522,9 +522,9 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
||||
resolved-ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
||||
url: "https://github.com/rustdesk-org/flutter_custom_cursor"
|
||||
ref: "ad940aaac3aada70958df2de761adda1a71aa2c4"
|
||||
resolved-ref: "ad940aaac3aada70958df2de761adda1a71aa2c4"
|
||||
url: "https://github.com/fufesou/flutter_custom_cursor"
|
||||
source: git
|
||||
version: "0.0.3"
|
||||
flutter_gpu_texture_renderer:
|
||||
|
||||
@@ -62,8 +62,8 @@ dependencies:
|
||||
freezed_annotation: ^2.0.3
|
||||
flutter_custom_cursor:
|
||||
git:
|
||||
url: https://github.com/rustdesk-org/flutter_custom_cursor
|
||||
ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
||||
url: https://github.com/fufesou/flutter_custom_cursor
|
||||
ref: ad940aaac3aada70958df2de761adda1a71aa2c4
|
||||
window_size:
|
||||
git:
|
||||
url: https://github.com/21pages/flutter-desktop-embedding.git
|
||||
|
||||
149
flutter/test/cursor_density_validation_test.dart
Normal file
149
flutter/test/cursor_density_validation_test.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_custom_cursor/cursor_manager.dart' show CursorManager;
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/common.dart' as common;
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'cursor_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
_rasterBoundsTests();
|
||||
final registrations = <Map<dynamic, dynamic>>[];
|
||||
captureNativeCursors(registrations);
|
||||
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||
for (final (density, width, dpr) in [
|
||||
(null, 4, 1.0),
|
||||
('0', 4, 2.0),
|
||||
(null, 32, 2.0),
|
||||
('0', 32, 1.0),
|
||||
]) {
|
||||
testWidgets(
|
||||
'legacy $style density=$density ${width}x32 DPR=$dpr',
|
||||
(tester) => tester.runAsync(() => checkDensity(
|
||||
tester, (style, density), registrations,
|
||||
sourceSize: Size(width.toDouble(), 32), dpr: dpr)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _rasterBoundsTests() {
|
||||
for (final (width, height, scale, legacy, rasterScale) in [
|
||||
(32, 32, 1e300, false, 1.0),
|
||||
(32, 32, 100.0, false, 2.0),
|
||||
]) {
|
||||
test('rejects raster ${width}x$height scale=$scale before key creation',
|
||||
() async {
|
||||
final image = await createTestImage(width: width, height: height);
|
||||
addTearDown(image.dispose);
|
||||
final data = CursorData(
|
||||
peerId: 'bounds',
|
||||
id: 'bounds',
|
||||
image: img.Image(width: width, height: height, numChannels: 4),
|
||||
nativeImage: image,
|
||||
scale: 1,
|
||||
data: null,
|
||||
hotxOrigin: 0,
|
||||
hotyOrigin: 0,
|
||||
width: width,
|
||||
height: height);
|
||||
final initial = data.updateGetKey(1, resizeImage: false);
|
||||
expect(
|
||||
data.updateGetKey(scale,
|
||||
resizeImage: false,
|
||||
useLegacyMinimum: legacy,
|
||||
rasterScale: rasterScale),
|
||||
isNull);
|
||||
expect(data.scale, 1);
|
||||
expect(data.updateGetKey(1, resizeImage: false), initial);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> checkDensity(WidgetTester tester, (String, String?) input,
|
||||
List<Map<dynamic, dynamic>> registrations,
|
||||
{Size sourceSize = const Size(32, 32), double dpr = 1}) async {
|
||||
final (style, density) = input;
|
||||
final id = '$style-$density';
|
||||
tester.view.devicePixelRatio = dpr;
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final canvas = CursorTestCanvas(dpr, style: style, scale: 0.5);
|
||||
final ffi = CursorTestFFI(canvas)..ffiModel.pi.platform = kPeerPlatformMacOS;
|
||||
final cursor = CursorModel(WeakReference(ffi))..id = id;
|
||||
addTearDown(() async {
|
||||
expect(cursor.parent.target, same(ffi));
|
||||
for (final key in cursor.cachedKeys) {
|
||||
await deleteCustomCursor(key);
|
||||
}
|
||||
cursor.disposeImages();
|
||||
cursor.dispose();
|
||||
canvas.dispose();
|
||||
});
|
||||
await cursor.updateCursorData(_cursorEvent(id, density, size: sourceSize));
|
||||
expect(cursor.cache!.pixelRatio, 0);
|
||||
await _paintCursor(tester, ffi, cursor);
|
||||
expect(tester.takeException(), isNull);
|
||||
for (final key in cursor.cachedKeys) {
|
||||
await CursorManager.instance.ensureCursorRegistered(key);
|
||||
}
|
||||
expect(registrations, hasLength(1));
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
expect(cursor.cache!.scale, 1.0);
|
||||
_expectLegacyRaster(registrations.single, sourceSize, dpr);
|
||||
}
|
||||
|
||||
Future<void> _paintCursor(
|
||||
WidgetTester tester, CursorTestFFI ffi, CursorModel cursor) async {
|
||||
await tester.pumpWidget(MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: ffi.canvasModel.devicePixelRatio),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>(create: (_) => CursorTestImage()),
|
||||
ChangeNotifierProvider<CanvasModel>.value(value: ffi.canvasModel),
|
||||
ChangeNotifierProvider<CursorModel>.value(value: cursor),
|
||||
],
|
||||
child: ImagePaint(
|
||||
ffi: ffi,
|
||||
id: 'density-review',
|
||||
zoomCursor: false.obs,
|
||||
cursorOverImage: true.obs,
|
||||
keyboardEnabled: true.obs,
|
||||
remoteCursorMoved: false.obs)),
|
||||
));
|
||||
}
|
||||
|
||||
void _expectLegacyRaster(Map<dynamic, dynamic> args, Size size, double dpr) {
|
||||
// Base unzoomed sizing is logical on macOS/GTK, physical on Windows.
|
||||
final rasterScale = common.isWindows ? 1.0 : dpr;
|
||||
final width = (size.width * rasterScale).toInt();
|
||||
final height = (size.height * rasterScale).toInt();
|
||||
final bufferWidth = common.isLinux ? size.longestSide * rasterScale : width;
|
||||
expect((args['width'], args['height']), (bufferWidth, height));
|
||||
expect((args['hotX'], args['hotY']),
|
||||
((size.width ~/ 2) * rasterScale, (size.height ~/ 2) * rasterScale));
|
||||
expect(args['imagePixelRatio'], dpr);
|
||||
final decoded = decodeNativeCursorRaster(args);
|
||||
expect((decoded.width, decoded.height), (bufferWidth, height));
|
||||
expect(decoded.getPixel(width - 1, height - 1).a, 255);
|
||||
if (bufferWidth > width) expect(decoded.getPixel(width, 0).a, 0);
|
||||
}
|
||||
|
||||
Map<String, String> _cursorEvent(String id, String? density,
|
||||
{Size size = const Size(32, 32)}) =>
|
||||
{
|
||||
'id': id,
|
||||
'width': '${size.width.toInt()}',
|
||||
'height': '${size.height.toInt()}',
|
||||
'hotx': '${size.width ~/ 2}',
|
||||
'hoty': '${size.height ~/ 2}',
|
||||
if (density != null) 'scale': density,
|
||||
'colors': jsonEncode(
|
||||
List<int>.filled((size.width * size.height * 4).toInt(), 255)),
|
||||
};
|
||||
236
flutter/test/cursor_dpi_policy_test.dart
Normal file
236
flutter/test/cursor_dpi_policy_test.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_custom_cursor/cursor_manager.dart' show CursorManager;
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'cursor_test_utils.dart';
|
||||
|
||||
class _Cursor extends CursorModel {
|
||||
_Cursor(this.cache, this._ffi) : super(WeakReference(_ffi));
|
||||
|
||||
final FFI _ffi;
|
||||
@override
|
||||
WeakReference<FFI> get parent => WeakReference(_ffi);
|
||||
|
||||
@override
|
||||
CursorData cache;
|
||||
@override
|
||||
double get hotx => cache.hotxOrigin;
|
||||
@override
|
||||
double get hoty => cache.hotyOrigin;
|
||||
}
|
||||
|
||||
class _LinuxDisplay extends Display {
|
||||
@override
|
||||
double get scale => 2;
|
||||
}
|
||||
|
||||
Future<CursorData> _data(int density, String id,
|
||||
{(int, int) source = (9, 18)}) async {
|
||||
final bitmapDensity = density == 0 ? 1 : density;
|
||||
final image = await createTestImage(
|
||||
width: source.$1 * bitmapDensity, height: source.$2 * bitmapDensity);
|
||||
return CursorData(
|
||||
peerId: 'dpi-policy',
|
||||
id: id,
|
||||
image: img.Image(width: image.width, height: image.height, numChannels: 4),
|
||||
nativeImage: image,
|
||||
scale: 1,
|
||||
data: Uint8List.fromList([1, 2]),
|
||||
hotxOrigin: (source.$1 ~/ 2) * bitmapDensity.toDouble(),
|
||||
hotyOrigin: (source.$2 ~/ 2) * bitmapDensity.toDouble(),
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
pixelRatio: density.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final view = binding.platformDispatcher.views.single;
|
||||
final windows = Platform.isWindows;
|
||||
final registrations = <Map<dynamic, dynamic>>[];
|
||||
captureNativeCursors(registrations);
|
||||
tearDown(view.resetDevicePixelRatio);
|
||||
for (final forbidden in [false, true]) {
|
||||
test('predefined cursor forbidden=$forbidden preserves native RGBA', () {
|
||||
view.devicePixelRatio = 1;
|
||||
return _checkPredefinedCursor(
|
||||
forbidden ? preForbiddenCursor : preDefaultCursor, registrations);
|
||||
});
|
||||
}
|
||||
for (final testCase in [
|
||||
(kRemoteViewStyleAdaptive, false, 0, 0.25, 1.0),
|
||||
(kRemoteViewStyleCustom, false, 0, 0.25, 1.0),
|
||||
(kRemoteViewStyleAdaptive, false, 1, 0.25, windows ? 2.0 : 1.0),
|
||||
(kRemoteViewStyleAdaptive, false, 2, 0.25, windows ? 1.0 : 0.5),
|
||||
(kRemoteViewStyleCustom, false, 2, 0.25, windows ? 1.0 : 0.5),
|
||||
(kRemoteViewStyleAdaptive, true, 2, 0.25, windows ? 2 / 3 : 1 / 3),
|
||||
(kRemoteViewStyleCustom, true, 2, 0.25, windows ? 2 / 3 : 1 / 3),
|
||||
(kRemoteViewStyleOriginal, false, 1, 0.5, windows ? 1.0 : 2 / 3),
|
||||
(kRemoteViewStyleOriginal, false, 2, 0.5, windows ? 1.0 : 0.5),
|
||||
]) {
|
||||
testWidgets(
|
||||
'${testCase.$1} zoom=${testCase.$2} peerDPR=${testCase.$3}',
|
||||
(tester) => tester
|
||||
.runAsync(() => _checkPolicy(tester, testCase, registrations)));
|
||||
}
|
||||
test('live DPR changes invalidate a cached native cursor',
|
||||
() => _checkDprChange(view, registrations));
|
||||
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||
final dpr = style == kRemoteViewStyleAdaptive ? 1.0 : 2.0;
|
||||
final source = style == kRemoteViewStyleAdaptive ? (64, 4) : (4, 64);
|
||||
testWidgets('$style unknown-density thin cursor preserves zoom', (tester) =>
|
||||
tester.runAsync(() => _checkPolicy(tester,
|
||||
(style, true, 0, 0.5, windows ? 0.5 * dpr : 0.5), registrations,
|
||||
dpr: dpr, source: source)));
|
||||
testWidgets('$style forbidden cursor ignores remote DPR', (tester) =>
|
||||
tester.runAsync(() => _checkPolicy(tester,
|
||||
(style, false, 2, 0.25, 0.5), registrations, dpr: 1)));
|
||||
testWidgets('Linux $style zoom follows video pixels', (tester) => tester.runAsync(
|
||||
() => _checkPolicy(tester, (style, true, 2, 1.0, windows ? 1.0 : 0.5),
|
||||
registrations, linux: true)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkPolicy(
|
||||
WidgetTester tester,
|
||||
(String, bool, int, double, double) testCase,
|
||||
List<Map<dynamic, dynamic>> registrations,
|
||||
{bool linux = false, double dpr = 2, (int, int) source = (9, 18)}) async {
|
||||
final (style, zoom, density, viewScale, expectedScale) = testCase;
|
||||
tester.view.devicePixelRatio = dpr;
|
||||
final data = await _data(density, '$style-$zoom-$density', source: source);
|
||||
final originalBytes = data.data;
|
||||
// A stale cached DPR must not affect the cursor when the window moves.
|
||||
final canvas = CursorTestCanvas(1, style: style, scale: viewScale);
|
||||
final ffi = CursorTestFFI(canvas);
|
||||
ffi.ffiModel.isPeerLinux = linux;
|
||||
if (linux) ffi.ffiModel.pi.displays.add(_LinuxDisplay());
|
||||
final cursor = _Cursor(data, ffi);
|
||||
final keyboardEnabled = true.obs;
|
||||
await tester.pumpWidget(MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>(create: (_) => CursorTestImage()),
|
||||
ChangeNotifierProvider<CanvasModel>.value(value: canvas),
|
||||
ChangeNotifierProvider<CursorModel>.value(value: cursor),
|
||||
],
|
||||
child: ImagePaint(
|
||||
ffi: ffi,
|
||||
id: 'dpi-policy',
|
||||
zoomCursor: zoom.obs,
|
||||
cursorOverImage: true.obs,
|
||||
keyboardEnabled: keyboardEnabled,
|
||||
remoteCursorMoved: false.obs,
|
||||
)),
|
||||
));
|
||||
final revoke = !zoom && style != kRemoteViewStyleOriginal;
|
||||
if (revoke) {
|
||||
await Future.wait(cursor.cachedKeys
|
||||
.map(CursorManager.instance.ensureCursorRegistered));
|
||||
keyboardEnabled.value = false;
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
for (final key in cursor.cachedKeys) {
|
||||
await deleteCustomCursor(key);
|
||||
}
|
||||
data.nativeImage.dispose();
|
||||
cursor.dispose();
|
||||
canvas.dispose();
|
||||
expect(data.scale, closeTo(expectedScale, 1e-9));
|
||||
expect(data.hotx, closeTo(data.hotxOrigin * expectedScale, 1e-9));
|
||||
expect(data.hoty, closeTo(data.hotyOrigin * expectedScale, 1e-9));
|
||||
expect(data.data, same(originalBytes));
|
||||
if (zoom && density == 0) {
|
||||
final args = registrations.single;
|
||||
final rasterScale = expectedScale * (Platform.isWindows ? 1 : dpr);
|
||||
final width = source.$1 * rasterScale;
|
||||
final height = source.$2 * rasterScale;
|
||||
expect((args['width'], args['height']),
|
||||
(Platform.isLinux && height > width ? height : width, height));
|
||||
expect((args['hotX'], args['hotY']),
|
||||
((source.$1 ~/ 2) * rasterScale, (source.$2 ~/ 2) * rasterScale));
|
||||
}
|
||||
if (revoke) {
|
||||
final args = registrations.last;
|
||||
expect(args['name'], contains('_${kPreForbiddenCursorId}_'));
|
||||
final rasterSize = 32 * (Platform.isWindows ? 1 : dpr);
|
||||
expect((args['width'], args['height']), (rasterSize, rasterSize));
|
||||
expect((args['hotX'], args['hotY']), (0.0, 0.0));
|
||||
}
|
||||
if (style == kRemoteViewStyleAdaptive && !zoom && density > 0) {
|
||||
final args = registrations.first;
|
||||
expect((args['width'], args['height']), ((Platform.isLinux ? 18 : 9) * dpr, 18 * dpr));
|
||||
expect((args['hotX'], args['hotY']), (4 * dpr, 9 * dpr));
|
||||
expect(args['imagePixelRatio'], dpr);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkDprChange(
|
||||
TestFlutterView view, List<Map<dynamic, dynamic>> registrations) async {
|
||||
// Keep the scale above the minimum so only DPR invalidates the cache.
|
||||
final data = await _data(2, 'dpr-cache');
|
||||
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 1);
|
||||
final cursor = _Cursor(data, CursorTestFFI(canvas));
|
||||
for (final dpr in [2.0, 1.0]) {
|
||||
view.devicePixelRatio = dpr;
|
||||
buildCursorOfCache(cursor, 1, data);
|
||||
await deleteCustomCursor(cursor.cachedKeys.last);
|
||||
}
|
||||
expect(registrations, hasLength(2));
|
||||
expect(registrations[0]['name'], isNot(registrations[1]['name']));
|
||||
data.nativeImage.dispose();
|
||||
cursor.dispose();
|
||||
canvas.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkPredefinedCursor(PredefinedCursor predefined,
|
||||
List<Map<dynamic, dynamic>> registrations) async {
|
||||
await Future.doWhile(() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
return predefined.cache == null;
|
||||
}).timeout(const Duration(seconds: 5));
|
||||
final cache = predefined.cache!;
|
||||
final original = img
|
||||
.decodePng(base64Decode(predefined.png))!
|
||||
.convert(format: img.Format.uint8, numChannels: 4);
|
||||
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 1);
|
||||
final cursor = _Cursor(cache, CursorTestFFI(canvas));
|
||||
buildCursorOfCache(cursor, 1, cache);
|
||||
await deleteCustomCursor(cursor.cachedKeys.single);
|
||||
cursor.dispose();
|
||||
canvas.dispose();
|
||||
|
||||
final args = registrations.single;
|
||||
final decoded = decodeNativeCursorRaster(args);
|
||||
expect((decoded.width, decoded.height), (original.width, original.height));
|
||||
expect(args['imagePixelRatio'], 1.0);
|
||||
expect((args['hotX'], args['hotY']), (cache.hotxOrigin, cache.hotyOrigin));
|
||||
expect(cache.image.getBytes(), original.getBytes());
|
||||
// Actual bundled pixels cover transparent, opaque and translucent colors.
|
||||
for (final (x, y) in [(0, 0), (1, 8), (13, 22), (16, 16)]) {
|
||||
final expected = original.getPixel(x, y);
|
||||
final actual = decoded.getPixel(x, y);
|
||||
expect(actual.a, expected.a);
|
||||
for (final (got, want) in [
|
||||
(actual.r, expected.r),
|
||||
(actual.g, expected.g),
|
||||
(actual.b, expected.b)
|
||||
]) {
|
||||
expect(got, closeTo(want, 1), reason: '${predefined.id} at ($x, $y)');
|
||||
}
|
||||
}
|
||||
}
|
||||
252
flutter/test/cursor_local_display_test.dart
Normal file
252
flutter/test/cursor_local_display_test.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_custom_cursor/cursor_manager.dart' as cursor_manager;
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'cursor_test_utils.dart';
|
||||
|
||||
const _viewport = Size(1920, 540);
|
||||
const _id = 'local-display-review';
|
||||
const _side = 64;
|
||||
const _hotspot = Offset(8, 12);
|
||||
|
||||
class _Display extends Display {
|
||||
_Display(this.density, double left, int w, int h) {
|
||||
x = left;
|
||||
width = w;
|
||||
height = h;
|
||||
}
|
||||
final double density;
|
||||
@override
|
||||
double get scale => density;
|
||||
}
|
||||
|
||||
class _Peer extends Fake implements FfiModel {
|
||||
_Peer() {
|
||||
pi.platform = kPeerPlatformLinux;
|
||||
pi.currentDisplay = kAllDisplayValue;
|
||||
pi.displays.assignAll([
|
||||
_Display(2, 0, 3840, 2160),
|
||||
_Display(1, 1920, 1920, 1080),
|
||||
]);
|
||||
}
|
||||
@override
|
||||
final pi = PeerInfo();
|
||||
@override
|
||||
bool get isPeerLinux => true;
|
||||
@override
|
||||
bool get keyboard => true;
|
||||
@override
|
||||
bool get viewOnly => false;
|
||||
@override
|
||||
Rect get rect => const Rect.fromLTWH(0, 0, 3840, 1080);
|
||||
}
|
||||
|
||||
class _Texture extends Fake implements TextureModel {
|
||||
final ids = <int, RxInt>{};
|
||||
@override
|
||||
RxInt getTextureId(int display) =>
|
||||
ids.putIfAbsent(display, () => display.obs);
|
||||
}
|
||||
|
||||
class _Canvas extends CanvasModel {
|
||||
_Canvas(FFI ffi, String style)
|
||||
: viewStyle = ViewStyle(
|
||||
style: style,
|
||||
width: 1920,
|
||||
height: 540,
|
||||
displayWidth: 3840,
|
||||
displayHeight: 1080),
|
||||
super(WeakReference(ffi)) {
|
||||
id = _id;
|
||||
}
|
||||
@override
|
||||
double get scale => 0.5;
|
||||
@override
|
||||
Size get size => _viewport;
|
||||
@override
|
||||
bool get cursorEmbedded => false;
|
||||
@override
|
||||
final ViewStyle viewStyle;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
_FFI(String style) {
|
||||
canvasModel = _Canvas(this, style);
|
||||
cursorModel = CursorModel(WeakReference(this));
|
||||
inputModel = InputModel(WeakReference(this));
|
||||
}
|
||||
@override
|
||||
final sessionId = Uuid().v4obj();
|
||||
@override
|
||||
String get id => _id;
|
||||
@override
|
||||
final ffiModel = _Peer();
|
||||
@override
|
||||
final imageModel = CursorTestImage(useTextureRender: true);
|
||||
@override
|
||||
final textureModel = _Texture();
|
||||
@override
|
||||
late final CanvasModel canvasModel;
|
||||
@override
|
||||
late final CursorModel cursorModel;
|
||||
@override
|
||||
late final InputModel inputModel;
|
||||
Offset? mappedPosition;
|
||||
}
|
||||
|
||||
Future<void> _shape(_FFI ffi, String id) async {
|
||||
ffi.cursorModel.id = id;
|
||||
await ffi.cursorModel.updateCursorData({
|
||||
'id': id,
|
||||
'width': '$_side',
|
||||
'height': '$_side',
|
||||
'scale': '2',
|
||||
'hotx': '${_hotspot.dx}',
|
||||
'hoty': '${_hotspot.dy}',
|
||||
'colors': jsonEncode(List.generate(_side * _side * 4, (_) => 255)),
|
||||
});
|
||||
}
|
||||
|
||||
Widget _widget(_FFI ffi, double dpr) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>.value(value: ffi.imageModel),
|
||||
ChangeNotifierProvider<CanvasModel>.value(value: ffi.canvasModel),
|
||||
ChangeNotifierProvider<CursorModel>.value(value: ffi.cursorModel),
|
||||
],
|
||||
child: ImagePaint(
|
||||
ffi: ffi,
|
||||
id: _id,
|
||||
zoomCursor: true.obs,
|
||||
cursorOverImage: true.obs,
|
||||
keyboardEnabled: true.obs,
|
||||
remoteCursorMoved: RemoteCursorMovedState.find(_id),
|
||||
listenerBuilder: (child) => Listener(
|
||||
child: child,
|
||||
onPointerHover: (e) {
|
||||
final point = ffi.inputModel.handlePointerDevicePos(
|
||||
kPointerEventKindMouse,
|
||||
e.position.dx,
|
||||
e.position.dy,
|
||||
true,
|
||||
kMouseEventTypeDefault,
|
||||
moveCanvas: false);
|
||||
if (point != null) {
|
||||
ffi.mappedPosition =
|
||||
Offset(point.x.toDouble(), point.y.toDouble());
|
||||
}
|
||||
})))));
|
||||
|
||||
Future<void> _settle(WidgetTester tester, _FFI ffi) async {
|
||||
await tester.pump();
|
||||
for (final key in ffi.cursorModel.cachedKeys) {
|
||||
await cursor_manager.CursorManager.instance.ensureCursorRegistered(key);
|
||||
}
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
final registrations = <Map<dynamic, dynamic>>[];
|
||||
final activations = <String>[];
|
||||
captureNativeCursors(registrations, activations: activations);
|
||||
setUp(() => RemoteCursorMovedState.init(_id));
|
||||
tearDown(() => RemoteCursorMovedState.delete(_id));
|
||||
for (final (style, dpr) in [
|
||||
(kRemoteViewStyleAdaptive, 1.0),
|
||||
(kRemoteViewStyleCustom, 2.0),
|
||||
]) {
|
||||
for (final mode in ['movement', 'shape refresh', 'host position control']) {
|
||||
testWidgets(
|
||||
'mixed displays: $style DPR=$dpr $mode',
|
||||
(tester) => tester.runAsync(
|
||||
() => _checkMovement(tester, (style, dpr, mode), (registrations, activations))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkMovement(
|
||||
WidgetTester tester,
|
||||
(String, double, String) testCase,
|
||||
(List<Map<dynamic, dynamic>>, List<String>) calls) async {
|
||||
final (style, dpr, mode) = testCase;
|
||||
final (registrations, activations) = calls;
|
||||
tester.view.devicePixelRatio = dpr;
|
||||
tester.view.physicalSize = _viewport * dpr;
|
||||
addTearDown(tester.view.reset);
|
||||
final ffi = _FFI(style);
|
||||
addTearDown(() => _dispose(ffi));
|
||||
await _shape(ffi, '$style-$dpr-$mode-1');
|
||||
await ffi.cursorModel.updateCursorPosition({'x': '100', 'y': '100'}, _id);
|
||||
ffi.canvasModel.updateLocalCursor(50, 50);
|
||||
await tester.pumpWidget(_widget(ffi, dpr));
|
||||
await _settle(tester, ffi);
|
||||
expect(registrations.last['width'], 16 * dpr);
|
||||
await _moveToB(tester, ffi, (mode, activations));
|
||||
final args = registrations.last;
|
||||
expect((args['width'], args['height']), (32 * dpr, 32 * dpr));
|
||||
expect((args['hotX'], args['hotY']), (4 * dpr, 6 * dpr));
|
||||
}
|
||||
|
||||
Future<void> _moveToB(WidgetTester tester, _FFI ffi, (String, List<String>) testCase) async {
|
||||
final (mode, activations) = testCase;
|
||||
final mouse = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
|
||||
await mouse.addPointer(location: const Offset(50, 50));
|
||||
await _settle(tester, ffi);
|
||||
final before = activations.length;
|
||||
for (final x in [60.0, 70.0, 80.0]) {
|
||||
await mouse.moveTo(Offset(x, 50));
|
||||
await _settle(tester, ffi);
|
||||
expect(activations.length, before);
|
||||
}
|
||||
await mouse.moveTo(
|
||||
Offset(1100 + CanvasModel.leftToEdge, 50 + CanvasModel.topToEdge));
|
||||
await _settle(tester, ffi);
|
||||
expect(activations.length, before + 1);
|
||||
expect(ffi.mappedPosition, const Offset(2200, 100));
|
||||
expect(ffi.cursorModel.offset, const Offset(100, 100));
|
||||
if (mode == 'shape refresh') {
|
||||
await _shape(ffi, '$mode-2');
|
||||
} else if (mode == 'host position control') {
|
||||
await ffi.cursorModel.updateCursorPosition({'x': '2200', 'y': '100'}, _id);
|
||||
ffi.canvasModel.updateLocalCursor(1100, 50);
|
||||
}
|
||||
await _settle(tester, ffi);
|
||||
final after = activations.length;
|
||||
expect(after, before + (mode == 'shape refresh' ? 2 : 1));
|
||||
for (final x in [1110.0, 1120.0, 1130.0]) {
|
||||
await mouse.moveTo(Offset(x, 50));
|
||||
await _settle(tester, ffi);
|
||||
expect(activations.length, after);
|
||||
}
|
||||
final displayB = tester.widgetList<Positioned>(find.byType(Positioned)).last;
|
||||
expect(displayB.width! / 1920, 0.5);
|
||||
await mouse.removePointer();
|
||||
}
|
||||
|
||||
Future<void> _dispose(_FFI ffi) async {
|
||||
for (final key in ffi.cursorModel.cachedKeys) {
|
||||
await deleteCustomCursor(key);
|
||||
}
|
||||
ffi.cursorModel.disposeImages();
|
||||
ffi.cursorModel.dispose();
|
||||
ffi.canvasModel.dispose();
|
||||
ffi.imageModel.dispose();
|
||||
// Relative mode is never started and this fixture has no Rust session.
|
||||
ffi.inputModel.disposeSideButtonTracking();
|
||||
}
|
||||
118
flutter/test/cursor_native_alpha_test.dart
Normal file
118
flutter/test/cursor_native_alpha_test.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
import 'cursor_test_utils.dart';
|
||||
|
||||
const _side = 32;
|
||||
const _hotspot = Offset(7, 9);
|
||||
const _colors = [
|
||||
[64, 32, 16, 128],
|
||||
[240, 100, 20, 255],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 128],
|
||||
];
|
||||
final _stripeWidth = _side ~/ _colors.length;
|
||||
|
||||
class _Canvas extends Fake implements CanvasModel {
|
||||
@override
|
||||
final viewStyle = ViewStyle(
|
||||
style: kRemoteViewStyleAdaptive,
|
||||
width: 200,
|
||||
height: 160,
|
||||
displayWidth: 200,
|
||||
displayHeight: 160);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final view = binding.platformDispatcher.views.single;
|
||||
final registrations = <Map<dynamic, dynamic>>[];
|
||||
captureNativeCursors(registrations);
|
||||
tearDown(view.resetDevicePixelRatio);
|
||||
for (final testCase in <(String, String?, List<int>)>[
|
||||
(kPeerPlatformMacOS, null, [64, 32, 16, 128]),
|
||||
(kPeerPlatformWindows, '0', [64, 32, 16, 128]),
|
||||
(kPeerPlatformLinux, '0', [32, 16, 8, 128]),
|
||||
]) {
|
||||
for (final dpr in [1.0, 2.0]) {
|
||||
test(
|
||||
'native ${testCase.$1} scale=${testCase.$2} DPR=$dpr preserves alpha',
|
||||
() {
|
||||
view.devicePixelRatio = dpr;
|
||||
return _checkCursor(testCase, dpr, registrations);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkCursor((String, String?, List<int>) testCase, double dpr,
|
||||
List<Map<dynamic, dynamic>> registrations) async {
|
||||
final (platform, density, color) = testCase;
|
||||
final ffi = CursorTestFFI(_Canvas())..ffiModel.pi.platform = platform;
|
||||
final cursor = CursorModel(WeakReference(ffi))..id = '$testCase-$dpr';
|
||||
addTearDown(() {
|
||||
cursor.disposeImages();
|
||||
cursor.dispose();
|
||||
});
|
||||
final palette = [color, ..._colors.skip(1)];
|
||||
final rgba = [
|
||||
for (var y = 0; y < _side; y++)
|
||||
for (var x = 0; x < _side; x++) ...palette[x ~/ _stripeWidth]
|
||||
];
|
||||
await cursor.updateCursorData({
|
||||
'id': '$testCase-$dpr',
|
||||
'width': '$_side',
|
||||
'height': '$_side',
|
||||
'hotx': '${_hotspot.dx}',
|
||||
'hoty': '${_hotspot.dy}',
|
||||
if (density != null) 'scale': density,
|
||||
'colors': jsonEncode(rgba),
|
||||
});
|
||||
final cache = cursor.cache!;
|
||||
buildCursorOfCache(cursor, Platform.isWindows ? dpr : 1, cache);
|
||||
await deleteCustomCursor(cursor.cachedKeys.single);
|
||||
expect(cache.image.getBytes(), rgba); // Keep the original byte-cache source.
|
||||
_checkRegistration(registrations.single, dpr);
|
||||
// The same ui.Image also supplies the painted remote cursor.
|
||||
final straight = await cache.nativeImage
|
||||
.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
|
||||
_checkColors(img.Image.fromBytes(
|
||||
width: _side,
|
||||
height: _side,
|
||||
bytes: straight!.buffer,
|
||||
bytesOffset: straight.offsetInBytes,
|
||||
order: img.ChannelOrder.rgba));
|
||||
// Real sessions own FFI strongly throughout asynchronous cursor decoding.
|
||||
expect(cursor.parent.target, same(ffi));
|
||||
}
|
||||
|
||||
void _checkRegistration(Map<dynamic, dynamic> args, double dpr) {
|
||||
final decoded = decodeNativeCursorRaster(args);
|
||||
expect((decoded.width, decoded.height), (_side * dpr, _side * dpr));
|
||||
expect((args['hotX'], args['hotY']), (_hotspot.dx * dpr, _hotspot.dy * dpr));
|
||||
_checkColors(decoded);
|
||||
}
|
||||
|
||||
void _checkColors(img.Image bitmap) {
|
||||
for (var stripe = 0; stripe < _colors.length; stripe++) {
|
||||
final pixel = bitmap.getPixel(
|
||||
((stripe + 0.5) * bitmap.width / _colors.length).floor(),
|
||||
bitmap.height ~/ 2);
|
||||
final expected = _colors[stripe];
|
||||
expect(pixel.a, expected.last);
|
||||
for (final (actual, wanted) in [
|
||||
(pixel.r, expected[0]),
|
||||
(pixel.g, expected[1]),
|
||||
(pixel.b, expected[2])
|
||||
]) {
|
||||
expect(actual, closeTo(wanted, 1), reason: 'RGBA stripe $stripe');
|
||||
}
|
||||
}
|
||||
}
|
||||
287
flutter/test/cursor_paint_scale_test.dart
Normal file
287
flutter/test/cursor_paint_scale_test.dart
Normal file
@@ -0,0 +1,287 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/utils/image.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'cursor_test_utils.dart';
|
||||
|
||||
const _hotspot = Offset(4, 9);
|
||||
const _remotePosition = Offset(100.25, 80.75);
|
||||
const _canvasOffset = Offset(15.125, -10.25);
|
||||
const _viewport = Size(200, 160);
|
||||
|
||||
class _CursorModel extends ChangeNotifier implements CursorModel {
|
||||
_CursorModel(this.image, this.position, this.density, {this.hotspot = _hotspot});
|
||||
|
||||
final Offset position;
|
||||
final double density;
|
||||
final Offset hotspot;
|
||||
@override
|
||||
CursorData get cache => _Density(density);
|
||||
|
||||
@override
|
||||
final ui.Image image;
|
||||
@override
|
||||
double get hotx => hotspot.dx;
|
||||
@override
|
||||
double get hoty => hotspot.dy;
|
||||
@override
|
||||
double get x => position.dx;
|
||||
@override
|
||||
double get y => position.dy;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Density extends Fake implements CursorData {
|
||||
_Density(this.pixelRatio);
|
||||
@override
|
||||
final double pixelRatio;
|
||||
}
|
||||
|
||||
class _ImageModel extends ChangeNotifier implements ImageModel {
|
||||
_ImageModel(this.useTextureRender);
|
||||
|
||||
@override
|
||||
final bool useTextureRender;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Display extends Display {
|
||||
_Display(this.scale, double left) {
|
||||
x = left;
|
||||
width = 3840;
|
||||
height = 2160;
|
||||
}
|
||||
|
||||
@override
|
||||
final double scale;
|
||||
}
|
||||
|
||||
class _Peer extends Fake implements FfiModel {
|
||||
@override
|
||||
final pi = PeerInfo()
|
||||
..displays.add(Display()
|
||||
..width = _viewport.width.toInt()
|
||||
..height = _viewport.height.toInt());
|
||||
@override
|
||||
bool isPeerLinux = false;
|
||||
@override
|
||||
Rect rect = Offset.zero & _viewport;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
_FFI(bool useTexture) : imageModel = _ImageModel(useTexture);
|
||||
|
||||
@override
|
||||
final ImageModel imageModel;
|
||||
@override
|
||||
final _Peer ffiModel = _Peer();
|
||||
@override
|
||||
final textureModel = CursorTestTexture();
|
||||
@override
|
||||
late CanvasModel canvasModel;
|
||||
}
|
||||
|
||||
class _CanvasModel extends ChangeNotifier implements CanvasModel {
|
||||
_CanvasModel(String style, this.scale, bool useTexture)
|
||||
: _ffi = _FFI(useTexture),
|
||||
viewStyle = ViewStyle(
|
||||
style: style,
|
||||
width: _viewport.width,
|
||||
height: _viewport.height,
|
||||
displayWidth: _viewport.width.toInt(),
|
||||
displayHeight: _viewport.height.toInt(),
|
||||
) {
|
||||
_ffi.canvasModel = this;
|
||||
}
|
||||
|
||||
final _FFI _ffi;
|
||||
@override
|
||||
WeakReference<FFI> get parent => WeakReference(_ffi);
|
||||
@override
|
||||
final imageOverflow = false.obs;
|
||||
@override
|
||||
final ViewStyle viewStyle;
|
||||
@override
|
||||
double get x => _canvasOffset.dx;
|
||||
@override
|
||||
double get y => _canvasOffset.dy;
|
||||
@override
|
||||
final double scale;
|
||||
@override
|
||||
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
|
||||
@override
|
||||
Size get size => _viewport;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _ScrollbarCanvasModel extends _CanvasModel {
|
||||
_ScrollbarCanvasModel(String style, {Size? frame, double scale = 2})
|
||||
: super(style, scale, true) {
|
||||
imageOverflow.value = true;
|
||||
if (frame != null) _ffi.ffiModel.rect = Offset.zero & frame;
|
||||
}
|
||||
|
||||
@override
|
||||
ScrollStyle get scrollStyle => ScrollStyle.scrollbar;
|
||||
@override
|
||||
double get scrollX => _ffi.ffiModel.rect.width * scale > size.width ? 0.1 : 0;
|
||||
@override
|
||||
double get scrollY => _ffi.ffiModel.rect.height * scale > size.height ? 0.2 : 0;
|
||||
}
|
||||
|
||||
Future<ImagePainter> _paintCursor(WidgetTester tester, CanvasModel canvas,
|
||||
{double dpr = 2, bool zoom = true, (int, int) source = (48, 64),
|
||||
Offset position = _remotePosition, double density = 0,
|
||||
Offset hotspot = _hotspot}) async {
|
||||
final image = (await tester
|
||||
.runAsync(() => createTestImage(width: source.$1, height: source.$2)))!;
|
||||
addTearDown(image.dispose);
|
||||
tester.view.devicePixelRatio = dpr;
|
||||
tester.view.physicalSize = _viewport * dpr;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr,
|
||||
child: MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>.value(value: canvas.parent.target!.imageModel),
|
||||
ChangeNotifierProvider<CursorModel>(create: (_) => _CursorModel(image, position, density, hotspot: hotspot)),
|
||||
ChangeNotifierProvider<CanvasModel>(create: (_) => canvas),
|
||||
],
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
// Measure the video's origin instead of copying its rounding policy.
|
||||
if (canvas.parent.target!.imageModel.useTextureRender &&
|
||||
canvas.scrollStyle == ScrollStyle.scrollauto)
|
||||
ImagePaint(ffi: canvas.parent.target!, id: 'cursor-test',
|
||||
zoomCursor: zoom.obs, cursorOverImage: false.obs,
|
||||
keyboardEnabled: true.obs, remoteCursorMoved: false.obs),
|
||||
CursorPaint(id: 'cursor-test', zoomCursor: zoom.obs),
|
||||
]),
|
||||
),
|
||||
)));
|
||||
final painter = tester.widget<CustomPaint>(find.byType(CustomPaint)).painter!
|
||||
as ImagePainter;
|
||||
expect(painter.image, same(image));
|
||||
return painter;
|
||||
}
|
||||
|
||||
void _linuxDisplayTests() {
|
||||
for (final (texture, displayScale, allDisplays) in [
|
||||
(false, 2.0, false), (true, 2.0, false), (true, 2.0, true),
|
||||
(false, 1.0, false), (true, 1.0, false),
|
||||
]) {
|
||||
testWidgets('Linux scale=$displayScale texture=$texture all=$allDisplays',
|
||||
(tester) async {
|
||||
final canvas = _CanvasModel(kRemoteViewStyleCustom, 2, texture);
|
||||
final peer = canvas._ffi.ffiModel;
|
||||
peer.isPeerLinux = true;
|
||||
// The first output's physical extent overlaps the second in logical
|
||||
// coordinates. Selection must use logical extents and the union origin.
|
||||
peer.pi.displays.assignAll([
|
||||
if (displayScale > 1) _Display(4, -960), _Display(displayScale, 0),
|
||||
]);
|
||||
peer.pi.currentDisplay = allDisplays ? kAllDisplayValue
|
||||
: peer.pi.displays.length - 1;
|
||||
peer.rect = Rect.fromLTWH(allDisplays ? -960 : 0, 0, 3840, 2160);
|
||||
final painter = await _paintCursor(tester, canvas,
|
||||
dpr: 1, zoom: false, source: (64, 64), density: 2,
|
||||
position: allDisplays ? _remotePosition + const Offset(960, 0)
|
||||
: _remotePosition);
|
||||
final pixelScale = 2 / displayScale;
|
||||
expect(painter.scale, pixelScale);
|
||||
final origin = texture ? tester.getTopLeft(find.byType(Texture).first)
|
||||
: Offset((_canvasOffset.dx / pixelScale).toInt() * pixelScale,
|
||||
(_canvasOffset.dy / pixelScale).toInt() * pixelScale);
|
||||
final position = allDisplays
|
||||
? _remotePosition + const Offset(960, 0) : _remotePosition;
|
||||
expect((Offset(painter.x, painter.y) + _hotspot) * painter.scale,
|
||||
position * 2 + origin);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
_linuxDisplayTests();
|
||||
final minimumScale = Platform.isWindows ? 1 / 3 : 2 / 3;
|
||||
for (final (style, zoom, dpr, source, canvasScale, scale, texture, density) in [
|
||||
(kRemoteViewStyleAdaptive, false, 2.0, (48, 64), 0.375, 0.375, true, 0.0),
|
||||
(kRemoteViewStyleOriginal, false, 2.0, (48, 64), 0.5, 0.5, true, 0.0),
|
||||
(kRemoteViewStyleCustom, false, 2.0, (48, 64), 0.25, 0.25, false, 0.0),
|
||||
(kRemoteViewStyleCustom, true, 2.0, (48, 64), 2.0, 2.0, false, 0.0),
|
||||
(kRemoteViewStyleAdaptive, false, 2.25, (48, 48), 0.375, 0.375, false, 0.0),
|
||||
(kRemoteViewStyleAdaptive, true, 2.0, (9, 18), 0.1, minimumScale, true, 0.0),
|
||||
(kRemoteViewStyleAdaptive, true, 1.0, (64, 4), 0.5, 0.5, true, 0.0),
|
||||
(kRemoteViewStyleCustom, true, 2.0, (4, 64), 0.5, 0.5, true, 0.0),
|
||||
(kRemoteViewStyleAdaptive, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||
(kRemoteViewStyleAdaptive, true, 2.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||
(kRemoteViewStyleCustom, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||
(kRemoteViewStyleCustom, true, 2.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||
(kRemoteViewStyleCustom, true, 2.0, (8, 10), 1.0, 1.2, true, 2.0),
|
||||
(kRemoteViewStyleOriginal, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||
(kRemoteViewStyleOriginal, true, 2.0, (48, 64), 0.05,
|
||||
Platform.isWindows ? 0.09375 : 0.1875, true, 2.0),
|
||||
]) {
|
||||
testWidgets(
|
||||
'$style zoom=$zoom dpr=$dpr source=$source texture=$texture density=$density keeps remote geometry',
|
||||
(tester) async {
|
||||
final sourceHotspot = source.$1 <= _hotspot.dx || source.$2 <= _hotspot.dy
|
||||
? Offset(source.$1 / 2, source.$2 / 2) : _hotspot;
|
||||
final painter = await _paintCursor(
|
||||
tester, _CanvasModel(style, canvasScale, texture),
|
||||
dpr: dpr, zoom: zoom, source: source, density: density,
|
||||
hotspot: sourceHotspot);
|
||||
expect(painter.scale, scale);
|
||||
var imageOrigin = texture
|
||||
? tester.getTopLeft(find.byType(Texture).first) : _canvasOffset;
|
||||
if (!texture) {
|
||||
final background = CursorTestDraw();
|
||||
ImagePainter(
|
||||
image: painter.image,
|
||||
x: _canvasOffset.dx / canvasScale,
|
||||
y: _canvasOffset.dy / canvasScale,
|
||||
scale: canvasScale,
|
||||
).paint(background, _viewport);
|
||||
imageOrigin = background.position!;
|
||||
}
|
||||
final target = _remotePosition * canvasScale + imageOrigin;
|
||||
final hotspot = (Offset(painter.x, painter.y) + sourceHotspot) * scale;
|
||||
expect(hotspot.dx, closeTo(target.dx, 1e-9));
|
||||
expect(hotspot.dy, closeTo(target.dy, 1e-9));
|
||||
final canvas = CursorTestDraw();
|
||||
painter.paint(canvas, _viewport);
|
||||
final position = canvas.position! + sourceHotspot * canvas.factor;
|
||||
expect(position.dx, closeTo(target.dx, 1e-9));
|
||||
expect(position.dy, closeTo(target.dy, 1e-9));
|
||||
});
|
||||
}
|
||||
for (final style in [kRemoteViewStyleOriginal, kRemoteViewStyleCustom]) {
|
||||
for (final (frame, scale, offset) in [
|
||||
(_viewport, 2.0, const Offset(-40, -64)),
|
||||
(const Size(199, 320), 1.0, const Offset(0, -64)),
|
||||
(const Size(198, 320), 1.0, const Offset(1, -64)),
|
||||
(const Size(400, 159), 1.0, const Offset(-40, 0)),
|
||||
(const Size(400, 158), 1.0, const Offset(-40, 1)),
|
||||
]) {
|
||||
testWidgets('$style frame=$frame painted cursor follows scrollbar layout',
|
||||
(tester) async {
|
||||
final painter = await _paintCursor(
|
||||
tester, _ScrollbarCanvasModel(style, frame: frame, scale: scale));
|
||||
final target = _remotePosition * scale + offset;
|
||||
expect((Offset(painter.x, painter.y) + _hotspot) * painter.scale, target);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
294
flutter/test/cursor_scroll_edge_test.dart
Normal file
294
flutter/test/cursor_scroll_edge_test.dart
Normal file
@@ -0,0 +1,294 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/utils/image.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:vector_math/vector_math.dart' show Vector2;
|
||||
|
||||
import 'cursor_test_utils.dart';
|
||||
|
||||
const _viewport = Size(200, 160);
|
||||
const _hotspot = Offset(4, 9);
|
||||
const _scrollDeltas = [80.0, -20.0, 1000.0, 1000.0, -1000.0, -1000.0, 80.0, 0.0];
|
||||
const _fittingViewports = [
|
||||
Size(500, 160), // Fit horizontally while vertical scrolling remains.
|
||||
Size(200, 400), // Fit vertically while horizontal scrolling remains.
|
||||
Size(400, 160), // Exact fit also detaches the controller.
|
||||
Size(200, 320),
|
||||
];
|
||||
|
||||
class _Peer extends Fake implements FfiModel {
|
||||
_Peer(ui.Image frame)
|
||||
: rect = Rect.fromLTWH(
|
||||
0, 0, frame.width.toDouble(), frame.height.toDouble()) {
|
||||
pi.displays.add(Display()
|
||||
..width = frame.width
|
||||
..height = frame.height);
|
||||
}
|
||||
@override
|
||||
final pi = PeerInfo();
|
||||
@override
|
||||
final Rect rect;
|
||||
@override
|
||||
bool get isPeerLinux => false;
|
||||
}
|
||||
|
||||
class _Image extends ImageModel {
|
||||
_Image(FFI ffi, this.image, this.useTextureRender)
|
||||
: super(WeakReference(ffi));
|
||||
@override
|
||||
final ui.Image image;
|
||||
@override
|
||||
final bool useTextureRender;
|
||||
}
|
||||
|
||||
class _ScrollCanvas extends CanvasModel {
|
||||
_ScrollCanvas(FFI ffi, String style, this.scrollStyle)
|
||||
: viewStyle = ViewStyle(
|
||||
style: style,
|
||||
width: _viewport.width,
|
||||
height: _viewport.height,
|
||||
displayWidth: ffi.ffiModel.rect!.width.toInt(),
|
||||
displayHeight: ffi.ffiModel.rect!.height.toInt()),
|
||||
super(WeakReference(ffi)) {
|
||||
resetOffset();
|
||||
imageOverflow.value = true;
|
||||
}
|
||||
Size viewport = _viewport;
|
||||
double imageScale = 1;
|
||||
@override
|
||||
Size get size => viewport;
|
||||
@override
|
||||
double get scale => imageScale;
|
||||
@override
|
||||
double get x => (size.width - getDisplayWidth() * scale) / 2;
|
||||
@override
|
||||
double get y => (size.height - getDisplayHeight() * scale) / 2;
|
||||
@override
|
||||
final ScrollStyle scrollStyle;
|
||||
@override
|
||||
final ViewStyle viewStyle;
|
||||
|
||||
void relayout(Size viewport, double dpr) {
|
||||
this.viewport = viewport;
|
||||
imageScale = 1 / dpr;
|
||||
imageOverflow.value = viewport.width < getDisplayWidth() * scale ||
|
||||
viewport.height < getDisplayHeight() * scale;
|
||||
setScrollPercent(0, 0);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class _Cursor extends CursorModel {
|
||||
_Cursor(FFI ffi, this.image, this.position) : super(WeakReference(ffi));
|
||||
final Offset position;
|
||||
@override
|
||||
final ui.Image image;
|
||||
@override
|
||||
double get hotx => _hotspot.dx;
|
||||
@override
|
||||
double get hoty => _hotspot.dy;
|
||||
@override
|
||||
double get x => position.dx;
|
||||
@override
|
||||
double get y => position.dy;
|
||||
}
|
||||
|
||||
class _FFI extends Fake implements FFI {
|
||||
_FFI(ui.Image frame, ui.Image cursor,
|
||||
{required bool texture, required (String, ScrollStyle) style, required Offset position})
|
||||
: ffiModel = _Peer(frame) {
|
||||
imageModel = _Image(this, frame, texture);
|
||||
canvasModel = _ScrollCanvas(this, style.$1, style.$2);
|
||||
cursorModel = _Cursor(this, cursor, position);
|
||||
}
|
||||
@override
|
||||
final sessionId = Uuid().v4obj();
|
||||
@override
|
||||
final _Peer ffiModel;
|
||||
@override
|
||||
late final ImageModel imageModel;
|
||||
@override
|
||||
late final CanvasModel canvasModel;
|
||||
@override
|
||||
late final CursorModel cursorModel;
|
||||
@override
|
||||
final textureModel = CursorTestTexture();
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final style in [(kRemoteViewStyleOriginal, ScrollStyle.scrolledge),
|
||||
(kRemoteViewStyleOriginal, ScrollStyle.scrollbar),
|
||||
(kRemoteViewStyleCustom, ScrollStyle.scrolledge),
|
||||
(kRemoteViewStyleCustom, ScrollStyle.scrollbar)]) {
|
||||
for (final texture in [false, true]) {
|
||||
for (final (frame, dpr) in [
|
||||
(const Size(199, 320), 1.0),
|
||||
(const Size(198, 320), 2.0),
|
||||
(const Size(400, 159), 2.0),
|
||||
(const Size(400, 158), 1.0),
|
||||
]) {
|
||||
testWidgets(
|
||||
'Scroll $style texture=$texture frame=$frame DPR=$dpr',
|
||||
(tester) => tester
|
||||
.runAsync(() => _check(tester, (style, texture, frame, dpr))));
|
||||
}
|
||||
for (final refreshBeforeLayout in [true, false]) {
|
||||
testWidgets(
|
||||
'Scroll relayout $style texture=$texture early=$refreshBeforeLayout',
|
||||
(tester) => tester.runAsync(() =>
|
||||
_checkRelayout(tester, (style, texture, refreshBeforeLayout))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _check(
|
||||
WidgetTester tester, ((String, ScrollStyle), bool, Size, double) testCase) async {
|
||||
final (style, texture, frame, dpr) = testCase;
|
||||
tester.view.devicePixelRatio = dpr;
|
||||
tester.view.physicalSize = _viewport * dpr;
|
||||
addTearDown(tester.view.reset);
|
||||
final vertical = frame.height > _viewport.height;
|
||||
final pointer =
|
||||
vertical ? const Offset(50.25, 200.75) : const Offset(240.25, 50.75);
|
||||
final video = await createTestImage(
|
||||
width: frame.width.toInt(), height: frame.height.toInt());
|
||||
final cursor = await createTestImage(width: 48, height: 64);
|
||||
final ffi =
|
||||
_FFI(video, cursor, texture: texture, style: style, position: pointer);
|
||||
addTearDown(() {
|
||||
ffi.canvasModel.scrollHorizontal.dispose();
|
||||
ffi.canvasModel.scrollVertical.dispose();
|
||||
ffi.canvasModel.dispose();
|
||||
ffi.imageModel.dispose();
|
||||
ffi.cursorModel.dispose();
|
||||
video.dispose();
|
||||
cursor.dispose();
|
||||
});
|
||||
await _mount(tester, ffi, dpr);
|
||||
final videoWidget = texture ? find.byType(Texture) : _paintOf(video);
|
||||
final scrolling = vertical
|
||||
? ffi.canvasModel.scrollVertical
|
||||
: ffi.canvasModel.scrollHorizontal;
|
||||
for (final distance in _scrollDeltas) {
|
||||
// Drive the real scroll controllers; canvas pan offsets are not the video origin.
|
||||
ffi.canvasModel.performEdgeScroll(
|
||||
vertical ? Vector2(0, distance) : Vector2(distance, 0));
|
||||
expect(vertical ? ffi.canvasModel.scrollY : ffi.canvasModel.scrollX,
|
||||
closeTo(scrolling.offset / (vertical ? frame.height : frame.width), 1e-9));
|
||||
await tester.pump();
|
||||
_expectAlignment(tester, ffi, videoWidget);
|
||||
}
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
}
|
||||
|
||||
Future<void> _checkRelayout(
|
||||
WidgetTester tester, ((String, ScrollStyle), bool, bool) testCase) async {
|
||||
final (style, texture, refreshBeforeLayout) = testCase;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = _viewport;
|
||||
addTearDown(tester.view.reset);
|
||||
final video = await createTestImage(width: 400, height: 320);
|
||||
final cursor = await createTestImage(width: 48, height: 64);
|
||||
final ffi = _FFI(video, cursor,
|
||||
texture: texture, style: style, position: const Offset(150.25, 120.75));
|
||||
final canvas = ffi.canvasModel as _ScrollCanvas;
|
||||
addTearDown(() {
|
||||
canvas.scrollHorizontal.dispose();
|
||||
canvas.scrollVertical.dispose();
|
||||
canvas.dispose();
|
||||
ffi.imageModel.dispose();
|
||||
ffi.cursorModel.dispose();
|
||||
video.dispose();
|
||||
cursor.dispose();
|
||||
});
|
||||
await _mount(tester, ffi, 1);
|
||||
final videoWidget = texture ? find.byType(Texture) : _paintOf(video);
|
||||
for (final (viewport, dpr) in [
|
||||
for (final viewport in _fittingViewports)
|
||||
...[(viewport, 1.0), (_viewport, 1.0)],
|
||||
(const Size(160, 120), 2.0),
|
||||
(const Size(195, 155), 2.0), // Clamp both existing scroll positions.
|
||||
(const Size(240, 155), 2.0), // Detach the horizontal scroll controller.
|
||||
(const Size(240, 200), 2.0), // No scrolling remains.
|
||||
(_viewport, 1.0),
|
||||
]) {
|
||||
canvas.performEdgeScroll(Vector2(20, 20));
|
||||
await tester.pump();
|
||||
_expectAlignment(tester, ffi, videoWidget);
|
||||
tester.view.devicePixelRatio = dpr;
|
||||
tester.view.physicalSize = viewport * dpr;
|
||||
canvas.relayout(viewport, dpr);
|
||||
// A settings refresh may run before layout or after the first cursor build.
|
||||
if (refreshBeforeLayout) {
|
||||
canvas.updateScrollPercent();
|
||||
} else {
|
||||
tester.binding.addPostFrameCallback((_) => canvas.updateScrollPercent());
|
||||
}
|
||||
await _mount(tester, ffi, dpr);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 16),
|
||||
EnginePhase.sendSemanticsUpdate, const Duration(seconds: 1));
|
||||
_expectAlignment(tester, ffi, videoWidget);
|
||||
}
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
}
|
||||
|
||||
void _expectAlignment(WidgetTester tester, FFI ffi, Finder videoWidget) {
|
||||
final cursor = ffi.cursorModel;
|
||||
final cursorWidget = _paintOf(cursor.image!);
|
||||
final painter =
|
||||
tester.widget<CustomPaint>(cursorWidget).painter! as ImagePainter;
|
||||
final output = CursorTestDraw();
|
||||
painter.paint(output, ffi.canvasModel.size);
|
||||
final hotspot = tester.getTopLeft(cursorWidget) +
|
||||
output.position! +
|
||||
_hotspot * output.factor;
|
||||
var videoOrigin = tester.getTopLeft(videoWidget);
|
||||
final video = tester.widget(videoWidget);
|
||||
if (video is CustomPaint) {
|
||||
final drawnVideo = CursorTestDraw();
|
||||
video.painter!.paint(drawnVideo, ffi.canvasModel.size);
|
||||
videoOrigin += drawnVideo.position!;
|
||||
}
|
||||
final target =
|
||||
videoOrigin + Offset(cursor.x, cursor.y) * ffi.canvasModel.scale;
|
||||
expect(hotspot.dx, closeTo(target.dx, 1e-9));
|
||||
expect(hotspot.dy, closeTo(target.dy, 1e-9));
|
||||
}
|
||||
|
||||
Finder _paintOf(ui.Image image) => find.byWidgetPredicate((widget) =>
|
||||
widget is CustomPaint &&
|
||||
widget.painter is ImagePainter &&
|
||||
identical((widget.painter as ImagePainter).image, image));
|
||||
|
||||
Future<void> _mount(WidgetTester tester, FFI ffi, double dpr) =>
|
||||
tester.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<ImageModel>.value(
|
||||
value: ffi.imageModel),
|
||||
ChangeNotifierProvider<CanvasModel>.value(
|
||||
value: ffi.canvasModel),
|
||||
ChangeNotifierProvider<CursorModel>.value(
|
||||
value: ffi.cursorModel),
|
||||
],
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
ImagePaint(
|
||||
ffi: ffi,
|
||||
id: 'scroll-edge-test',
|
||||
zoomCursor: false.obs,
|
||||
cursorOverImage: false.obs,
|
||||
keyboardEnabled: true.obs,
|
||||
remoteCursorMoved: false.obs),
|
||||
CursorPaint(id: 'scroll-edge-test', zoomCursor: false.obs),
|
||||
])))));
|
||||
127
flutter/test/cursor_test_utils.dart
Normal file
127
flutter/test/cursor_test_utils.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/common.dart' as common;
|
||||
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
class CursorTestImage extends ChangeNotifier implements ImageModel {
|
||||
CursorTestImage({this.useTextureRender = false});
|
||||
@override
|
||||
final bool useTextureRender;
|
||||
@override
|
||||
ui.Image? get image => null;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class CursorTestCanvas extends ChangeNotifier implements CanvasModel {
|
||||
CursorTestCanvas(this.devicePixelRatio,
|
||||
{required this.style, required this.scale});
|
||||
|
||||
final String style;
|
||||
@override
|
||||
final double devicePixelRatio;
|
||||
@override
|
||||
final imageOverflow = false.obs;
|
||||
@override
|
||||
late final viewStyle = ViewStyle(
|
||||
style: style,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
displayWidth: 400,
|
||||
displayHeight: 320,
|
||||
);
|
||||
@override
|
||||
bool get cursorEmbedded => false;
|
||||
@override
|
||||
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
|
||||
@override
|
||||
Size get size => const Size(200, 160);
|
||||
@override
|
||||
final double scale;
|
||||
@override
|
||||
double get x => 0;
|
||||
@override
|
||||
double get y => 0;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Input extends Fake implements InputModel {
|
||||
@override
|
||||
final relativeMouseMode = false.obs;
|
||||
}
|
||||
|
||||
class CursorTestPeer extends Fake implements FfiModel {
|
||||
@override
|
||||
final pi = PeerInfo();
|
||||
@override
|
||||
bool isPeerLinux = false;
|
||||
}
|
||||
|
||||
class CursorTestFFI extends Fake implements FFI {
|
||||
CursorTestFFI(this.canvasModel);
|
||||
@override
|
||||
final CanvasModel canvasModel;
|
||||
@override
|
||||
final inputModel = _Input();
|
||||
@override
|
||||
final CursorTestPeer ffiModel = CursorTestPeer();
|
||||
}
|
||||
|
||||
void captureNativeCursors(List<Map<dynamic, dynamic>> registrations,
|
||||
{List<String>? activations}) {
|
||||
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final channel = common.isWindows
|
||||
? SystemChannels.mouseCursor
|
||||
: const MethodChannel('flutter_custom_cursor');
|
||||
setUp(() {
|
||||
registrations.clear();
|
||||
activations?.clear();
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
|
||||
(call) async {
|
||||
if (call.method.startsWith('setCustomCursor')) {
|
||||
activations?.add(call.arguments['name'] as String);
|
||||
}
|
||||
if (!call.method.startsWith('createCustomCursor')) return null;
|
||||
final args = call.arguments as Map<dynamic, dynamic>;
|
||||
registrations.add(args);
|
||||
return args['name'];
|
||||
});
|
||||
});
|
||||
tearDown(() =>
|
||||
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
|
||||
}
|
||||
|
||||
img.Image decodeNativeCursorRaster(Map<dynamic, dynamic> args) {
|
||||
final bytes = args['buffer'] as Uint8List;
|
||||
return common.isWindows
|
||||
? img.Image.fromBytes(
|
||||
width: args['width'] as int,
|
||||
height: args['height'] as int,
|
||||
bytes: bytes.buffer,
|
||||
bytesOffset: bytes.offsetInBytes,
|
||||
order: img.ChannelOrder.bgra)
|
||||
: img.decodePng(bytes)!;
|
||||
}
|
||||
|
||||
class CursorTestTexture extends Fake implements TextureModel {
|
||||
@override
|
||||
RxInt getTextureId(int display) => 0.obs;
|
||||
}
|
||||
|
||||
class CursorTestDraw extends Fake implements Canvas {
|
||||
double factor = 1;
|
||||
Offset? position;
|
||||
@override
|
||||
void scale(double sx, [double? sy]) => factor *= sx;
|
||||
@override
|
||||
void drawImage(ui.Image image, Offset offset, Paint paint) =>
|
||||
position = offset * factor;
|
||||
}
|
||||
276
flutter/test/cursor_web_test.dart
Normal file
276
flutter/test/cursor_web_test.dart
Normal file
@@ -0,0 +1,276 @@
|
||||
// The test drives the cursor lifecycle normally owned by MouseTracker.
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
@TestOn('browser')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:js' as js;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/models/model.dart' as model;
|
||||
import 'package:flutter_hbb/web/custom_cursor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'cursor_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
_alphaTests();
|
||||
_thinCursorTests();
|
||||
_sourceValidationTests();
|
||||
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||
for (final dpr in [1.0, 2.0]) {
|
||||
for (final (platform, density) in [
|
||||
(kPeerPlatformMacOS, null), (kPeerPlatformMacOS, 0.0),
|
||||
(kPeerPlatformMacOS, 1.0), (kPeerPlatformMacOS, 2.0),
|
||||
(kPeerPlatformLinux, 2.0), (kPeerPlatformWindows, 2.0),
|
||||
]) {
|
||||
testWidgets('Web $style zoom off DPR $dpr $platform density=$density',
|
||||
(tester) => tester.runAsync(() => _checkPolicy(tester, style, dpr,
|
||||
pixelRatio: density, platform: platform)));
|
||||
}
|
||||
}
|
||||
}
|
||||
test('Web cursor aligns CSS hotspots with rounded PNG dimensions', () async {
|
||||
final registered = _captureCursor();
|
||||
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 0.5);
|
||||
addTearDown(canvas.dispose);
|
||||
final ffi = CursorTestFFI(canvas);
|
||||
for (final (hotspot, scale, side, expected) in [
|
||||
((7.0, 7.0), 633 / 1600, 19, (3, 3)),
|
||||
((21.0, 23.0), 633 / 1600, 19, (8, 9)),
|
||||
((22.0, 22.0), 633 / 1600, 19, (9, 9)),
|
||||
((21.0, 23.0), 0.05, 12, (5, 6)),
|
||||
((21.0, 23.0), 0.5, 24, (11, 12)),
|
||||
((7.0, 7.0), 1.0, 48, (7, 7)),
|
||||
]) {
|
||||
final cursor = await _loadCursor(ffi, '$hotspot-$scale',
|
||||
hotspot: hotspot, pixelRatio: null);
|
||||
final session =
|
||||
buildCursorOfCache(cursor, scale, cursor.cache).createSession(1);
|
||||
await session.activate();
|
||||
final uri = Uri.parse(registered['url'] as String);
|
||||
final bitmap = img.decodePng(uri.data!.contentAsBytes())!;
|
||||
expect((bitmap.width, bitmap.height), (side, side));
|
||||
expect((registered['hotx'], registered['hoty']), expected);
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _sourceValidationTests() {
|
||||
const side = 48;
|
||||
const sourceLimit = 4096;
|
||||
const bytes = side * side * 4;
|
||||
for (final density in [null, 4.0]) {
|
||||
test('Web rejects invalid cursor sources and recovers, density=$density', () async {
|
||||
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 1);
|
||||
addTearDown(canvas.dispose);
|
||||
final cursor = await _loadCursor(CursorTestFFI(canvas), 'source-$density',
|
||||
source: (side, side), pixelRatio: density);
|
||||
final cache = cursor.cache;
|
||||
final image = cursor.image;
|
||||
final event = {
|
||||
'id': 'source-$density', 'width': '$side', 'height': '$side',
|
||||
'hotx': '7', 'hoty': '9',
|
||||
if (density != null) 'scale': '$density',
|
||||
'colors': jsonEncode(List<int>.filled(bytes, 255)),
|
||||
};
|
||||
// Invalid JSON proves geometry is checked before decoding or allocation.
|
||||
const invalidPixels = 'must not decode an invalid source size';
|
||||
for (final invalid in [
|
||||
{'colors': '[1,2,3,4]'},
|
||||
{'colors': jsonEncode(List<int>.filled(bytes + 1, 255))},
|
||||
{'width': '0', 'colors': invalidPixels},
|
||||
{'height': '-1', 'colors': invalidPixels},
|
||||
{'width': '${sourceLimit * 4}', 'height': '${sourceLimit * 4}',
|
||||
'colors': invalidPixels},
|
||||
{'scale': '1e-300'},
|
||||
{'scale': '0.001'},
|
||||
]) {
|
||||
await cursor.updateCursorData({...event, ...invalid});
|
||||
expect(cursor.cache, same(cache));
|
||||
expect(cursor.image, same(image));
|
||||
expect((cursor.hotx, cursor.hoty), (7, 9));
|
||||
}
|
||||
await cursor.updateCursorData(event);
|
||||
expect(cursor.cache, isNot(same(cache)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _thinCursorTests() {
|
||||
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||
for (final zoom in [false, true]) {
|
||||
testWidgets('ImagePaint Web $style thin cursor zoom=$zoom', (tester) =>
|
||||
tester.runAsync(() => _checkPolicy(tester, style, 1,
|
||||
zoom: zoom, source: (64, 4), hotspot: (32, 2),
|
||||
expectedSize: zoom ? (32, 2) : (64, 4),
|
||||
expectedHotspot: zoom ? (16, 1) : (32, 2))));
|
||||
}
|
||||
}
|
||||
test('Web thin cursors keep a raster pixel and an in-bounds hotspot', () async {
|
||||
final registered = _captureCursor();
|
||||
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 0.5);
|
||||
addTearDown(canvas.dispose);
|
||||
final ffi = CursorTestFFI(canvas);
|
||||
for (final (source, hotspot, scale, size, expected) in [
|
||||
((4, 64), (2.0, 32.0), 0.5, (2, 32), (1, 16)),
|
||||
((2, 128), (1.0, 64.0), 0.01, (1, 12), (0, 6)),
|
||||
((128, 2), (64.0, 1.0), 0.01, (12, 1), (6, 0)),
|
||||
]) {
|
||||
final cursor = await _loadCursor(ffi, '$source',
|
||||
source: source, hotspot: hotspot);
|
||||
for (final factor in [1.0, scale, 1.0]) {
|
||||
final session =
|
||||
buildCursorOfCache(cursor, factor, cursor.cache).createSession(1);
|
||||
await session.activate();
|
||||
session.dispose();
|
||||
final png = img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
|
||||
expect((png.width, png.height), factor == 1 ? source : size);
|
||||
expect((registered['hotx'], registered['hoty']),
|
||||
factor == 1 ? (hotspot.$1, hotspot.$2) : expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<model.CursorModel> _loadCursor(model.FFI ffi, String id,
|
||||
{List<int> pixel = const [255, 255, 255, 255],
|
||||
(int, int) source = (48, 48),
|
||||
(double, double) hotspot = (7, 9),
|
||||
double? pixelRatio = 1}) async {
|
||||
final cursor = model.CursorModel(WeakReference(ffi))..id = id;
|
||||
await cursor.updateCursorData({
|
||||
'id': id,
|
||||
'width': '${source.$1}',
|
||||
'height': '${source.$2}',
|
||||
'hotx': '${hotspot.$1}',
|
||||
'hoty': '${hotspot.$2}',
|
||||
if (pixelRatio != null) 'scale': '$pixelRatio',
|
||||
'colors': jsonEncode([for (var i = 0; i < source.$1 * source.$2; i++) ...pixel]),
|
||||
});
|
||||
addTearDown(() async {
|
||||
// Keep the session owner alive across asynchronous image decoding.
|
||||
expect(cursor.parent.target, same(ffi));
|
||||
for (final key in cursor.cachedKeys) {
|
||||
await deleteCustomCursor(key);
|
||||
}
|
||||
cursor.disposeImages();
|
||||
cursor.dispose();
|
||||
});
|
||||
return cursor;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _captureCursor() {
|
||||
final originals = {
|
||||
for (final key in ['isMobile', 'getByName', 'setByName'])
|
||||
key: js.context[key]
|
||||
};
|
||||
js.context['isMobile'] = js.allowInterop(() => false);
|
||||
js.context['getByName'] = js.allowInterop((String name, String value) => '');
|
||||
final registered = <String, dynamic>{};
|
||||
js.context['setByName'] = js.allowInterop((String name, String value) {
|
||||
if (name == 'cursor') {
|
||||
registered
|
||||
..clear()
|
||||
..addAll(jsonDecode(value));
|
||||
}
|
||||
});
|
||||
addTearDown(() {
|
||||
for (final entry in originals.entries) {
|
||||
js.context[entry.key] = entry.value;
|
||||
}
|
||||
});
|
||||
return registered;
|
||||
}
|
||||
|
||||
Future<void> _checkPolicy(WidgetTester tester, String style, double dpr,
|
||||
{bool zoom = false, (int, int) source = (48, 48),
|
||||
(double, double) hotspot = (7, 9), (int, int) expectedSize = (48, 48),
|
||||
(int, int) expectedHotspot = (7, 9), double? pixelRatio = 1,
|
||||
String platform = kPeerPlatformMacOS}) async {
|
||||
final registered = _captureCursor();
|
||||
final canvas = CursorTestCanvas(1, style: style, scale: 0.5);
|
||||
addTearDown(canvas.dispose);
|
||||
final ffi = CursorTestFFI(canvas);
|
||||
ffi.ffiModel.pi.platform = platform;
|
||||
// Retina export changes the bitmap too; varying metadata alone misses this boundary.
|
||||
final density = platform == kPeerPlatformMacOS && pixelRatio == 2 ? 2 : 1;
|
||||
final cursor = await _loadCursor(ffi, '$style-$dpr-$zoom-$source-$platform-$pixelRatio',
|
||||
source: (source.$1 * density, source.$2 * density), pixelRatio: pixelRatio,
|
||||
hotspot: (hotspot.$1 * density, hotspot.$2 * density));
|
||||
await tester.pumpWidget(MediaQuery(
|
||||
data: MediaQueryData(devicePixelRatio: dpr),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<model.ImageModel>(create: (_) => CursorTestImage()),
|
||||
ChangeNotifierProvider<model.CanvasModel>.value(value: canvas),
|
||||
ChangeNotifierProvider<model.CursorModel>.value(value: cursor),
|
||||
],
|
||||
child: ImagePaint(
|
||||
ffi: ffi,
|
||||
id: 'web-cursor-test',
|
||||
zoomCursor: zoom.obs,
|
||||
cursorOverImage: true.obs,
|
||||
keyboardEnabled: true.obs,
|
||||
remoteCursorMoved: false.obs)),
|
||||
));
|
||||
final session = tester
|
||||
.widget<MouseRegion>(find.byType(MouseRegion))
|
||||
.cursor
|
||||
.createSession(1);
|
||||
await session.activate();
|
||||
session.dispose();
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
final png =
|
||||
img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
|
||||
expect((png.width, png.height), expectedSize);
|
||||
expect((registered['hotx'], registered['hoty']), expectedHotspot);
|
||||
}
|
||||
|
||||
void _alphaTests() {
|
||||
for (final (density, pixel) in [
|
||||
(1.0, [255, 255, 255, 128]),
|
||||
(2.0, [255, 128, 64, 128]),
|
||||
(1.0, [255, 128, 64, 112]),
|
||||
(1.0, [0, 0, 0, 0]),
|
||||
(1.0, [255, 255, 255, 255]),
|
||||
// Old macOS hosts send straight alpha without density metadata.
|
||||
(0.0, [255, 128, 64, 128]),
|
||||
(null, [80, 40, 20, 128]),
|
||||
]) {
|
||||
test('Web macOS cursor density $density preserves RGBA $pixel',
|
||||
() => _checkAlpha(density, pixel));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkAlpha(double? density, List<int> pixel) async {
|
||||
final registered = _captureCursor();
|
||||
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 0.5);
|
||||
addTearDown(canvas.dispose);
|
||||
final ffi = CursorTestFFI(canvas)..ffiModel.pi.platform = kPeerPlatformMacOS;
|
||||
final cursor = await _loadCursor(ffi, 'alpha-$density-$pixel',
|
||||
pixel: pixel, pixelRatio: density);
|
||||
// Cover both the painted remote cursor and the initial CSS cursor PNG.
|
||||
final straight = await cursor.image!
|
||||
.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
|
||||
expect(straight!.buffer.asUint8List(0, 4), pixel);
|
||||
for (final scale in [1.0, 0.5, 1.0]) {
|
||||
final session =
|
||||
buildCursorOfCache(cursor, scale, cursor.cache).createSession(1);
|
||||
await session.activate();
|
||||
session.dispose();
|
||||
final png =
|
||||
img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
|
||||
final color = png.getPixel(png.width ~/ 2, png.height ~/ 2);
|
||||
expect([color.r, color.g, color.b, color.a], pixel);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
final _sessionId = UuidValue('00000000-0000-0000-0000-000000000000');
|
||||
|
||||
class _FakeFFI implements FFI {
|
||||
@override
|
||||
UuidValue get sessionId => _sessionId;
|
||||
|
||||
@override
|
||||
late final FfiModel ffiModel = FfiModel(WeakReference(this));
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('the quality monitor names the WebRTC transport only on web', () {
|
||||
final ffi = _FakeFFI();
|
||||
ffi.ffiModel.cachedPeerData.streamType = 'WebRTC';
|
||||
final model = QualityMonitorModel(WeakReference(ffi));
|
||||
// Off the web the session tab's tooltip already names the transport.
|
||||
expect(isWeb, isFalse);
|
||||
expect(model.webrtcTransport, isNull);
|
||||
});
|
||||
}
|
||||
@@ -310,12 +310,16 @@ message KeyEvent {
|
||||
}
|
||||
|
||||
message CursorData {
|
||||
uint64 id = 1;
|
||||
uint64 id = 1 [jstype = JS_STRING];
|
||||
sint32 hotx = 2;
|
||||
sint32 hoty = 3;
|
||||
int32 width = 4;
|
||||
int32 height = 5;
|
||||
bytes colors = 6;
|
||||
// Physical bitmap pixels per remote logical pixel; 0 means unknown density.
|
||||
double scale = 7;
|
||||
// Optional physical image. Legacy receivers keep using the logical-sized fields above.
|
||||
CursorData high_resolution = 8;
|
||||
}
|
||||
|
||||
message CursorPosition {
|
||||
@@ -999,7 +1003,7 @@ message Message {
|
||||
AudioFrame audio_frame = 11;
|
||||
CursorData cursor_data = 12;
|
||||
CursorPosition cursor_position = 13;
|
||||
uint64 cursor_id = 14;
|
||||
uint64 cursor_id = 14 [jstype = JS_STRING];
|
||||
KeyEvent key_event = 15;
|
||||
Clipboard clipboard = 16;
|
||||
FileAction file_action = 17;
|
||||
|
||||
@@ -129,7 +129,6 @@ pub const OPTION_ENABLE_UDP_PUNCH: &str = "enable-udp-punch";
|
||||
pub const OPTION_ENABLE_IPV6_PUNCH: &str = "enable-ipv6-punch";
|
||||
pub const OPTION_ENABLE_PORT_FORWARD_MUX: &str = "enable-port-forward-mux";
|
||||
pub const OPTION_ENABLE_WEBRTC: &str = "enable-webrtc";
|
||||
pub const OPTION_RELAY_FALLBACK_DELAY: &str = "relay-fallback-delay";
|
||||
pub const OPTION_ALLOW_KCP_CC: &str = "allow-kcp-congestion-control";
|
||||
pub const OPTION_HIDE_USERNAME_ON_CARD: &str = "hide-username-on-card";
|
||||
pub const OPTION_HIDE_HELP_CARDS: &str = "hide-help-cards";
|
||||
@@ -259,7 +258,6 @@ pub const KEYS_LOCAL_SETTINGS: &[&str] = &[
|
||||
OPTION_ENABLE_IPV6_PUNCH,
|
||||
OPTION_ENABLE_PORT_FORWARD_MUX,
|
||||
OPTION_ENABLE_WEBRTC,
|
||||
OPTION_RELAY_FALLBACK_DELAY,
|
||||
OPTION_TOUCH_MODE,
|
||||
OPTION_SHOW_VIRTUAL_MOUSE,
|
||||
OPTION_SHOW_VIRTUAL_JOYSTICK,
|
||||
|
||||
@@ -320,6 +320,9 @@ pub struct WaylandDisplayInfo {
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub logical_size: Option<(i32, i32)>,
|
||||
// wl_output.scale is independent of desktop coordinates. Older probes omit it.
|
||||
#[serde(default)]
|
||||
pub scale_factor: i32,
|
||||
pub refresh_rate: i32,
|
||||
/// Output rotation in degrees (0/90/180/270), from `wl_output.geometry`. The mode keeps its
|
||||
/// unrotated dimensions and `logical_size` arrives already swapped, so without this field a
|
||||
@@ -450,6 +453,7 @@ fn collect_wayland_displays(conn: &Connection) -> ResultType<Vec<WaylandDisplayI
|
||||
width,
|
||||
height,
|
||||
logical_size,
|
||||
scale_factor: info.scale_factor,
|
||||
refresh_rate,
|
||||
transform,
|
||||
});
|
||||
@@ -542,9 +546,11 @@ mod tests {
|
||||
let old = r#"{"name":"HDMI-1","x":0,"y":0,"width":1920,"height":1080,"logical_size":null,"refresh_rate":60}"#;
|
||||
let info: WaylandDisplayInfo = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(info.transform, 0);
|
||||
assert_eq!(info.scale_factor, 0);
|
||||
let roundtrip: WaylandDisplayInfo =
|
||||
serde_json::from_str(&serde_json::to_string(&info).unwrap()).unwrap();
|
||||
assert_eq!(roundtrip.transform, 0);
|
||||
assert_eq!(roundtrip.scale_factor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Submodule libs/hbb_common updated: daf84c2df3...29cf7cbe4d
@@ -41,13 +41,6 @@ impl<T> Drop for ComPtr<T> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum FrameState {
|
||||
Idle,
|
||||
Acquired,
|
||||
Mapped,
|
||||
}
|
||||
|
||||
pub struct Capturer {
|
||||
device: ComPtr<ID3D11Device>,
|
||||
display: Display,
|
||||
@@ -65,7 +58,6 @@ pub struct Capturer {
|
||||
output_texture: bool,
|
||||
adapter_desc1: DXGI_ADAPTER_DESC1,
|
||||
rotate: Rotate,
|
||||
frame_state: FrameState,
|
||||
}
|
||||
|
||||
impl Capturer {
|
||||
@@ -182,7 +174,6 @@ impl Capturer {
|
||||
output_texture: false,
|
||||
adapter_desc1,
|
||||
rotate,
|
||||
frame_state: FrameState::Idle,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -344,7 +335,6 @@ impl Capturer {
|
||||
let mut info = mem::MaybeUninit::uninit().assume_init();
|
||||
|
||||
wrap_hresult((*self.duplication.0).AcquireNextFrame(timeout, &mut info, &mut frame))?;
|
||||
self.frame_state = FrameState::Acquired;
|
||||
let frame = ComPtr(frame);
|
||||
|
||||
if *info.LastPresentTime.QuadPart() == 0 {
|
||||
@@ -355,11 +345,9 @@ impl Capturer {
|
||||
let mut rect = mem::MaybeUninit::uninit().assume_init();
|
||||
if self.fastlane {
|
||||
wrap_hresult((*self.duplication.0).MapDesktopSurface(&mut rect))?;
|
||||
self.frame_state = FrameState::Mapped;
|
||||
} else {
|
||||
self.surface = ComPtr(self.ohgodwhat(frame.0)?);
|
||||
wrap_hresult((*self.surface.0).Map(&mut rect, DXGI_MAP_READ))?;
|
||||
self.frame_state = FrameState::Mapped;
|
||||
}
|
||||
Ok((rect.pBits, rect.Pitch))
|
||||
}
|
||||
@@ -436,7 +424,7 @@ impl Capturer {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.release_frame()?;
|
||||
self.unmap();
|
||||
let r = self.load_frame(timeout)?;
|
||||
let rotate = match self.display.rotation() {
|
||||
DXGI_MODE_ROTATION_IDENTITY | DXGI_MODE_ROTATION_UNSPECIFIED => kRotate0,
|
||||
@@ -484,13 +472,12 @@ impl Capturer {
|
||||
if self.duplication.0.is_null() {
|
||||
return Err(std::io::ErrorKind::AddrNotAvailable.into());
|
||||
}
|
||||
self.release_frame()?;
|
||||
(*self.duplication.0).ReleaseFrame();
|
||||
let mut frame = ptr::null_mut();
|
||||
#[allow(invalid_value)]
|
||||
let mut info = mem::MaybeUninit::uninit().assume_init();
|
||||
|
||||
wrap_hresult((*self.duplication.0).AcquireNextFrame(timeout, &mut info, &mut frame))?;
|
||||
self.frame_state = FrameState::Acquired;
|
||||
let frame = ComPtr(frame);
|
||||
|
||||
if info.AccumulatedFrames == 0 || *info.LastPresentTime.QuadPart() == 0 {
|
||||
@@ -587,42 +574,16 @@ impl Capturer {
|
||||
}
|
||||
}
|
||||
|
||||
fn release_frame(&mut self) -> io::Result<()> {
|
||||
if self.duplication.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut first_error = None;
|
||||
// Unmap before ReleaseFrame invalidates the desktop surface; use the same
|
||||
// order for staging surfaces. Cleanup advances Mapped -> Acquired -> Idle,
|
||||
// while Idle is a no-op. Advance state even on errors to avoid retrying
|
||||
// cleanup, but still attempt ReleaseFrame if unmapping fails.
|
||||
fn unmap(&self) {
|
||||
unsafe {
|
||||
if self.frame_state == FrameState::Mapped {
|
||||
let result = if self.fastlane {
|
||||
wrap_hresult((*self.duplication.0).UnMapDesktopSurface())
|
||||
} else if !self.surface.is_null() {
|
||||
wrap_hresult((*self.surface.0).Unmap())
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
self.frame_state = FrameState::Acquired;
|
||||
if let Err(err) = result {
|
||||
first_error = Some(err);
|
||||
(*self.duplication.0).ReleaseFrame();
|
||||
if self.fastlane {
|
||||
(*self.duplication.0).UnMapDesktopSurface();
|
||||
} else {
|
||||
if !self.surface.is_null() {
|
||||
(*self.surface.0).Unmap();
|
||||
}
|
||||
}
|
||||
if self.frame_state == FrameState::Acquired {
|
||||
let result = wrap_hresult((*self.duplication.0).ReleaseFrame());
|
||||
self.frame_state = FrameState::Idle;
|
||||
if first_error.is_none() {
|
||||
if let Err(err) = result {
|
||||
first_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match first_error {
|
||||
Some(err) => Err(err),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,8 +599,8 @@ impl Capturer {
|
||||
|
||||
impl Drop for Capturer {
|
||||
fn drop(&mut self) {
|
||||
if let Err(err) = self.release_frame() {
|
||||
eprintln!("DXGI frame cleanup failed: {err}");
|
||||
if !self.duplication.is_null() {
|
||||
self.unmap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,25 @@ pub fn wayland_failure_stamped() -> bool {
|
||||
LAST_FAILED_LOOKUP.lock().unwrap().is_some()
|
||||
}
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
pub enum CachedDisplays {
|
||||
Busy,
|
||||
Ready(Option<Arc<Displays>>),
|
||||
}
|
||||
|
||||
/// Cursor polls must neither wait for discovery nor mistake contention for missing metadata.
|
||||
#[cfg(feature = "drm")]
|
||||
pub fn get_cached_displays() -> CachedDisplays {
|
||||
match DISPLAYS.try_lock() {
|
||||
Ok(cache) => CachedDisplays::Ready(cache.clone()),
|
||||
Err(std::sync::TryLockError::WouldBlock) => CachedDisplays::Busy,
|
||||
Err(err) => {
|
||||
warn!("Failed to read cached Wayland displays: {}", err);
|
||||
CachedDisplays::Ready(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_displays() -> Arc<Displays> {
|
||||
let mut lock = DISPLAYS.lock().unwrap();
|
||||
match lock.as_ref() {
|
||||
@@ -337,6 +356,17 @@ pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec<Display
|
||||
}
|
||||
match enumerate_displays() {
|
||||
Ok(displays) => {
|
||||
#[cfg(feature = "drm")]
|
||||
{
|
||||
// Output density can change without moving any desktop-coordinate rectangle.
|
||||
let mut cache = DISPLAYS.lock().unwrap();
|
||||
if let Some(updated) = cache
|
||||
.as_deref()
|
||||
.and_then(|cached| updated_cursor_scales(cached, &displays))
|
||||
{
|
||||
*cache = Some(Arc::new(updated));
|
||||
}
|
||||
}
|
||||
desktop_rect_of(&displays).map(|rect| (rect, logical_rects_of(&displays)))
|
||||
}
|
||||
Err(_err) => {
|
||||
@@ -347,6 +377,30 @@ pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec<Display
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
fn updated_cursor_scales(cached: &Displays, live: &[WaylandDisplayInfo]) -> Option<Displays> {
|
||||
let mut displays = cached.displays.clone();
|
||||
let mut changed = false;
|
||||
for output in &mut displays {
|
||||
// Geometry changes retain the existing layout invalidation/rebuild path.
|
||||
let Some(current) = live.iter().find(|d| {
|
||||
d.name == output.name
|
||||
&& (d.x, d.y) == (output.x, output.y)
|
||||
&& (d.width, d.height) == (output.width, output.height)
|
||||
&& d.transform == output.transform
|
||||
&& d.logical_size == output.logical_size
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
changed |= output.scale_factor != current.scale_factor;
|
||||
output.scale_factor = current.scale_factor;
|
||||
}
|
||||
changed.then_some(Displays {
|
||||
primary: cached.primary,
|
||||
displays,
|
||||
})
|
||||
}
|
||||
|
||||
fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i32)> {
|
||||
if displays.is_empty() {
|
||||
return None;
|
||||
@@ -516,6 +570,27 @@ fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_e
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
#[test]
|
||||
fn cursor_metadata_does_not_wait_for_display_discovery() {
|
||||
let cache = DISPLAYS.lock().unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
tx.send(matches!(get_cached_displays(), CachedDisplays::Busy))
|
||||
.unwrap();
|
||||
});
|
||||
// Discovery owns this lock until it completes or times out.
|
||||
let result = rx.recv_timeout(Duration::from_secs(1));
|
||||
drop(cache);
|
||||
reader.join().unwrap();
|
||||
assert!(result.unwrap());
|
||||
|
||||
clear_wayland_displays_cache();
|
||||
for _ in 0..100 {
|
||||
assert!(matches!(get_cached_displays(), CachedDisplays::Ready(None)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_backoff_boundaries() {
|
||||
// Future `now`s sidestep Instant subtraction, which can panic near boot.
|
||||
@@ -564,6 +639,7 @@ mod tests {
|
||||
width,
|
||||
height,
|
||||
logical_size,
|
||||
scale_factor: 1,
|
||||
refresh_rate: 60,
|
||||
transform: 0,
|
||||
}
|
||||
@@ -574,6 +650,33 @@ mod tests {
|
||||
assert_eq!(desktop_rect_of(&[]), None);
|
||||
}
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
#[test]
|
||||
fn output_scale_refresh_preserves_layout_and_existing_snapshots() {
|
||||
let mut output = display(0, 0, 2560, 1600, Some((2560, 1600)));
|
||||
output.name = "eDP-1".into();
|
||||
output.scale_factor = 3;
|
||||
let cached = Displays {
|
||||
primary: 0,
|
||||
displays: vec![output.clone()],
|
||||
};
|
||||
output.scale_factor = 2;
|
||||
let updated = updated_cursor_scales(&cached, &[output.clone()]).unwrap();
|
||||
assert_eq!(updated.displays[0].scale_factor, 2);
|
||||
assert_eq!(cached.displays[0].scale_factor, 3);
|
||||
assert_eq!(updated.primary, cached.primary);
|
||||
assert_eq!(
|
||||
logical_rects_of(&updated.displays),
|
||||
logical_rects_of(&cached.displays)
|
||||
);
|
||||
assert!(updated_cursor_scales(&updated, &[output.clone()]).is_none());
|
||||
output.name = "HDMI-1".into();
|
||||
assert!(updated_cursor_scales(&cached, &[output.clone()]).is_none());
|
||||
output.name = "eDP-1".into();
|
||||
output.logical_size = Some((1280, 800));
|
||||
assert!(updated_cursor_scales(&cached, &[output]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desktop_rect_single_display_uses_physical_size() {
|
||||
let displays = [display(0, 0, 2880, 1800, Some((1859, 1162)))];
|
||||
|
||||
@@ -10,7 +10,7 @@ if [ "$1" = configure ]; then
|
||||
if [ "systemd" == "$INITSYS" ]; then
|
||||
|
||||
if [ -e /etc/systemd/system/rustdesk.service ]; then
|
||||
rm -f /etc/systemd/system/rustdesk.service /usr/lib/systemd/system/rustdesk.service /usr/lib/systemd/user/rustdesk.service >/dev/null 2>&1
|
||||
rm /etc/systemd/system/rustdesk.service /usr/lib/systemd/system/rustdesk.service /usr/lib/systemd/user/rustdesk.service >/dev/null 2>&1
|
||||
fi
|
||||
mkdir -p /usr/lib/systemd/system/
|
||||
cp /usr/share/rustdesk/files/systemd/rustdesk.service /usr/lib/systemd/system/rustdesk.service
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# FFmpeg 7.1.1 includes the HEVC WPP slice-thread deadlock fix:
|
||||
# https://github.com/FFmpeg/FFmpeg/commit/79c47dfd25f101b6842bbec8c6ffef8d5077c3ae
|
||||
vcpkg_from_github(
|
||||
OUT_SOURCE_PATH SOURCE_PATH
|
||||
REPO ffmpeg/ffmpeg
|
||||
REF "n${VERSION}"
|
||||
SHA512 6b9a5ee501be41d6abc7579a106263b31f787321cbc45dedee97abf992bf8236cdb2394571dd256a74154f4a20018d429ae7e7f0409611ddc4d6f529d924d175
|
||||
SHA512 3b273769ef1a1b63aed0691eef317a760f8c83b1d0e1c232b67bbee26db60b4864aafbc88df0e86d6bebf07185bbd057f33e2d5258fde6d97763b9994cd48b6f
|
||||
HEAD_REF master
|
||||
PATCHES
|
||||
0001-create-lib-libraries.patch
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "ffmpeg",
|
||||
"version": "7.1.1",
|
||||
"version": "7.1",
|
||||
"port-version": 1,
|
||||
"description": [
|
||||
"a library to decode, encode, transcode, mux, demux, stream, filter and play pretty much anything that humans and machines have created.",
|
||||
"FFmpeg is the leading multimedia framework, able to decode, encode, transcode, mux, demux, stream, filter and play pretty much anything that humans and machines have created. It supports the most obscure ancient formats up to the cutting edge. No matter if they were designed by some standards committee, the community or a corporation. It is also highly portable: FFmpeg compiles, runs, and passes our testing infrastructure FATE across Linux, Mac OS X, Microsoft Windows, the BSDs, Solaris, etc. under a wide variety of build environments, machine architectures, and configurations."
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
use hbb_common::thiserror;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod allocation_tests;
|
||||
|
||||
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
|
||||
mod sinc;
|
||||
|
||||
const INTERPOLATION_MARGIN_FRAMES: usize = 2;
|
||||
const PENDING_PACKET_CAPACITY: usize = 2;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct AudioResamplerConfig {
|
||||
pub input_rate: u32,
|
||||
pub output_rate: u32,
|
||||
pub channels: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub(crate) enum AudioResamplerError {
|
||||
#[error(
|
||||
"invalid audio resampler configuration: input_rate={}, output_rate={}, channels={}",
|
||||
.0.input_rate, .0.output_rate, .0.channels
|
||||
)]
|
||||
InvalidConfig(AudioResamplerConfig),
|
||||
#[error("invalid resampler output frame size: {output_frames}")]
|
||||
InvalidOutputFrameSize { output_frames: usize },
|
||||
#[error("audio resampler input length {samples} is not divisible by channel count {channels}")]
|
||||
IncompleteFrame { samples: usize, channels: usize },
|
||||
#[error("audio resampler output capacity overflow")]
|
||||
CapacityOverflow,
|
||||
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
|
||||
#[error("audio resampler backend failed: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
pub(crate) struct FixedFrameAudioResampler {
|
||||
resampler: AudioResampler,
|
||||
output_samples: usize,
|
||||
pending_samples: Vec<f32>,
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
|
||||
// SAFETY: libsamplerate's src_new state owns heap data and has no thread affinity.
|
||||
// This wrapper never exposes or shares that state; processing requires &mut self.
|
||||
unsafe impl Send for FixedFrameAudioResampler {}
|
||||
|
||||
impl FixedFrameAudioResampler {
|
||||
pub(crate) fn new(
|
||||
config: AudioResamplerConfig,
|
||||
output_frames: usize,
|
||||
) -> Result<Self, AudioResamplerError> {
|
||||
if output_frames == 0 {
|
||||
return Err(AudioResamplerError::InvalidOutputFrameSize { output_frames });
|
||||
}
|
||||
let channels = validate_config(config)?;
|
||||
let output_samples = output_frames
|
||||
.checked_mul(channels)
|
||||
.ok_or(AudioResamplerError::CapacityOverflow)?;
|
||||
let input_frames = output_frames
|
||||
.checked_mul(config.input_rate as usize)
|
||||
.ok_or(AudioResamplerError::CapacityOverflow)?
|
||||
.div_ceil(config.output_rate as usize);
|
||||
let capacity = output_samples
|
||||
.checked_mul(PENDING_PACKET_CAPACITY)
|
||||
.and_then(|samples| samples.checked_add(channels * INTERPOLATION_MARGIN_FRAMES))
|
||||
.ok_or(AudioResamplerError::CapacityOverflow)?;
|
||||
let mut resampler = AudioResampler::new(config)?;
|
||||
resampler.reserve_input(input_frames)?;
|
||||
Ok(Self {
|
||||
resampler,
|
||||
output_samples,
|
||||
pending_samples: Vec::with_capacity(capacity),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn process_with(
|
||||
&mut self,
|
||||
input: &[f32],
|
||||
mut on_packet: impl FnMut(&[f32]),
|
||||
) -> Result<(), AudioResamplerError> {
|
||||
self.resampler
|
||||
.process_into(input, &mut self.pending_samples)?;
|
||||
let complete_samples =
|
||||
self.pending_samples.len() / self.output_samples * self.output_samples;
|
||||
for packet in self.pending_samples[..complete_samples].chunks_exact(self.output_samples) {
|
||||
on_packet(packet);
|
||||
}
|
||||
self.pending_samples.drain(..complete_samples);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn process(&mut self, input: &[f32]) -> Result<Vec<Vec<f32>>, AudioResamplerError> {
|
||||
let mut packets = Vec::new();
|
||||
self.process_with(input, |packet| packets.push(packet.to_owned()))?;
|
||||
Ok(packets)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AudioResampler {
|
||||
#[cfg(not(all(feature = "use_samplerate", not(feature = "use_dasp"))))]
|
||||
backend: StreamingLinearAudioResampler,
|
||||
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
|
||||
backend: sinc::SincAudioResampler,
|
||||
}
|
||||
|
||||
impl AudioResampler {
|
||||
pub(crate) fn new(config: AudioResamplerConfig) -> Result<Self, AudioResamplerError> {
|
||||
Ok(Self {
|
||||
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
|
||||
backend: sinc::SincAudioResampler::new(config)?,
|
||||
#[cfg(not(all(feature = "use_samplerate", not(feature = "use_dasp"))))]
|
||||
backend: StreamingLinearAudioResampler::new(config)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn process(&mut self, input: &[f32]) -> Result<Vec<f32>, AudioResamplerError> {
|
||||
let mut output = Vec::new();
|
||||
self.process_into(input, &mut output)?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
// Append samples so capture can retain an incomplete output packet in the same buffer.
|
||||
fn process_into(
|
||||
&mut self,
|
||||
input: &[f32],
|
||||
output: &mut Vec<f32>,
|
||||
) -> Result<(), AudioResamplerError> {
|
||||
self.backend.process_into(input, output)
|
||||
}
|
||||
|
||||
fn reserve_input(&mut self, _frames: usize) -> Result<(), AudioResamplerError> {
|
||||
#[cfg(not(all(feature = "use_samplerate", not(feature = "use_dasp"))))]
|
||||
{
|
||||
let capacity = _frames
|
||||
.checked_add(INTERPOLATION_MARGIN_FRAMES)
|
||||
.and_then(|frames| frames.checked_mul(self.backend.channels))
|
||||
.ok_or(AudioResamplerError::CapacityOverflow)?;
|
||||
self.backend.buffered_samples.reserve(capacity);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "use_samplerate", not(feature = "use_dasp"))))]
|
||||
struct StreamingLinearAudioResampler {
|
||||
config: AudioResamplerConfig,
|
||||
channels: usize,
|
||||
buffered_samples: Vec<f32>,
|
||||
next_position: u64,
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "use_samplerate", not(feature = "use_dasp"))))]
|
||||
impl StreamingLinearAudioResampler {
|
||||
fn new(config: AudioResamplerConfig) -> Result<Self, AudioResamplerError> {
|
||||
Ok(Self {
|
||||
config,
|
||||
channels: validate_config(config)?,
|
||||
buffered_samples: Vec::new(),
|
||||
next_position: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn process_into(
|
||||
&mut self,
|
||||
input: &[f32],
|
||||
output: &mut Vec<f32>,
|
||||
) -> Result<(), AudioResamplerError> {
|
||||
validate_input(input, self.channels)?;
|
||||
let capacity = self.output_capacity(input.len())?;
|
||||
output.reserve(capacity);
|
||||
self.buffered_samples.extend_from_slice(input);
|
||||
while self.write_next_frame(output) {
|
||||
self.next_position += self.config.input_rate as u64;
|
||||
}
|
||||
self.discard_consumed_frames();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn output_capacity(&self, input_samples: usize) -> Result<usize, AudioResamplerError> {
|
||||
let input_frames = input_samples / self.channels;
|
||||
let scaled_frames = input_frames
|
||||
.checked_mul(self.config.output_rate as usize)
|
||||
.ok_or(AudioResamplerError::CapacityOverflow)?
|
||||
/ self.config.input_rate as usize;
|
||||
scaled_frames
|
||||
.checked_add(INTERPOLATION_MARGIN_FRAMES)
|
||||
.and_then(|frames| frames.checked_mul(self.channels))
|
||||
.ok_or(AudioResamplerError::CapacityOverflow)
|
||||
}
|
||||
|
||||
fn write_next_frame(&self, output: &mut Vec<f32>) -> bool {
|
||||
let output_rate = self.config.output_rate as u64;
|
||||
let frame_count = self.buffered_samples.len() / self.channels;
|
||||
let frame = (self.next_position / output_rate) as usize;
|
||||
let fraction = self.next_position % output_rate;
|
||||
if frame >= frame_count || (fraction != 0 && frame + 1 >= frame_count) {
|
||||
return false;
|
||||
}
|
||||
let weight = fraction as f32 / output_rate as f32;
|
||||
for channel in 0..self.channels {
|
||||
let current = self.buffered_samples[frame * self.channels + channel];
|
||||
let next_frame = frame + usize::from(fraction != 0);
|
||||
let next = self.buffered_samples[next_frame * self.channels + channel];
|
||||
output.push(current + (next - current) * weight);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn discard_consumed_frames(&mut self) {
|
||||
let output_rate = self.config.output_rate as u64;
|
||||
let available_frames = self.buffered_samples.len() / self.channels;
|
||||
let consumed_frames = ((self.next_position / output_rate) as usize).min(available_frames);
|
||||
self.buffered_samples
|
||||
.drain(0..consumed_frames * self.channels);
|
||||
self.next_position -= consumed_frames as u64 * output_rate;
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_config(config: AudioResamplerConfig) -> Result<usize, AudioResamplerError> {
|
||||
if config.input_rate == 0 || config.output_rate == 0 || config.channels == 0 {
|
||||
return Err(AudioResamplerError::InvalidConfig(config));
|
||||
}
|
||||
Ok(config.channels as usize)
|
||||
}
|
||||
|
||||
fn validate_input(input: &[f32], channels: usize) -> Result<(), AudioResamplerError> {
|
||||
if input.len() % channels != 0 {
|
||||
return Err(AudioResamplerError::IncompleteFrame {
|
||||
samples: input.len(),
|
||||
channels,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(all(feature = "use_samplerate", not(feature = "use_dasp")))))]
|
||||
mod tests;
|
||||
|
||||
#[cfg(all(test, feature = "use_samplerate", not(feature = "use_dasp")))]
|
||||
mod samplerate_tests;
|
||||
@@ -1,142 +0,0 @@
|
||||
use super::{AudioResamplerConfig, FixedFrameAudioResampler};
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::cell::Cell;
|
||||
|
||||
struct CountingAllocator;
|
||||
|
||||
thread_local! {
|
||||
static ALLOCATIONS: Cell<Option<usize>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
fn record_allocation() {
|
||||
let _ = ALLOCATIONS.try_with(|count| {
|
||||
if let Some(value) = count.get() {
|
||||
count.set(Some(value + 1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unsafe impl GlobalAlloc for CountingAllocator {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
record_allocation();
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
record_allocation();
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
|
||||
record_allocation();
|
||||
unsafe { System.realloc(ptr, layout, size) }
|
||||
}
|
||||
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
unsafe { System.dealloc(ptr, layout) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: CountingAllocator = CountingAllocator;
|
||||
|
||||
pub(crate) fn assert_no_allocations(process: impl FnOnce()) {
|
||||
struct ResetCounter;
|
||||
impl Drop for ResetCounter {
|
||||
fn drop(&mut self) {
|
||||
ALLOCATIONS.with(|count| count.set(None));
|
||||
}
|
||||
}
|
||||
|
||||
ALLOCATIONS.with(|count| assert!(count.replace(Some(0)).is_none()));
|
||||
let reset = ResetCounter;
|
||||
process();
|
||||
let allocations = ALLOCATIONS.with(|count| count.get().unwrap());
|
||||
drop(reset);
|
||||
assert_eq!(
|
||||
allocations, 0,
|
||||
"PCM processing allocated on the capture thread"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_resampling_reuses_buffers() {
|
||||
const PACKETS_PER_SECOND: usize = 100;
|
||||
const PACKET_COUNT: usize = 100;
|
||||
const MAX_STARTUP_DELAY_PACKETS: usize = 1;
|
||||
const SIGNAL_LEVEL: f32 = 0.25;
|
||||
const RATE_PAIRS: [(u32, u32); 6] = [
|
||||
(32_000, 24_000),
|
||||
(44_100, 24_000),
|
||||
(44_100, 48_000),
|
||||
(48_000, 24_000),
|
||||
(96_000, 48_000),
|
||||
(192_000, 48_000),
|
||||
];
|
||||
|
||||
for (input_rate, output_rate) in RATE_PAIRS {
|
||||
for channels in [1, 2, 4, 6, 8] {
|
||||
let config = AudioResamplerConfig {
|
||||
input_rate,
|
||||
output_rate,
|
||||
channels,
|
||||
};
|
||||
let input =
|
||||
vec![SIGNAL_LEVEL; input_rate as usize / PACKETS_PER_SECOND * channels as usize];
|
||||
let frames = output_rate as usize / PACKETS_PER_SECOND;
|
||||
let mut resampler = FixedFrameAudioResampler::new(config, frames).unwrap();
|
||||
let mut packets = 0;
|
||||
let mut energy = 0.0;
|
||||
assert_no_allocations(|| {
|
||||
for _ in 0..PACKET_COUNT {
|
||||
resampler
|
||||
.process_with(&input, |packet| {
|
||||
assert_eq!(packet.len(), frames * channels as usize);
|
||||
energy += packet.iter().map(|sample| sample * sample).sum::<f32>();
|
||||
packets += 1;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
assert!((PACKET_COUNT - MAX_STARTUP_DELAY_PACKETS..=PACKET_COUNT).contains(&packets));
|
||||
assert!(energy > SIGNAL_LEVEL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
|
||||
#[test]
|
||||
fn sinc_output_matches_the_existing_backend() {
|
||||
use super::AudioResampler;
|
||||
|
||||
const INPUT_FRAMES: usize = 2_048;
|
||||
const CHUNK_FRAMES: usize = 73;
|
||||
const SIGNAL_STEP: f32 = 0.07;
|
||||
for (input_rate, output_rate) in [(44_100, 24_000), (44_100, 48_000), (96_000, 48_000)] {
|
||||
for channels in [1, 2, 4, 6, 8] {
|
||||
let config = AudioResamplerConfig {
|
||||
input_rate,
|
||||
output_rate,
|
||||
channels,
|
||||
};
|
||||
let input: Vec<_> = (0..INPUT_FRAMES * channels as usize)
|
||||
.map(|sample| (sample as f32 * SIGNAL_STEP).sin())
|
||||
.collect();
|
||||
let mut actual = AudioResampler::new(config).unwrap();
|
||||
let expected = samplerate::Samplerate::new(
|
||||
samplerate::ConverterType::SincBestQuality,
|
||||
input_rate,
|
||||
output_rate,
|
||||
channels as usize,
|
||||
)
|
||||
.unwrap();
|
||||
for chunk in input.chunks(CHUNK_FRAMES * channels as usize) {
|
||||
assert_eq!(
|
||||
actual.process(chunk).unwrap(),
|
||||
expected.process(chunk).unwrap()
|
||||
);
|
||||
assert_eq!(actual.process(&[]).unwrap(), expected.process(&[]).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
use super::{AudioResampler, AudioResamplerConfig, AudioResamplerError, FixedFrameAudioResampler};
|
||||
|
||||
const INPUT_RATE: u32 = 44_100;
|
||||
const OUTPUT_RATE: u32 = 48_000;
|
||||
const CHANNELS: u16 = 2;
|
||||
const INPUT_PACKET_FRAMES: usize = INPUT_RATE as usize / PACKETS_PER_SECOND;
|
||||
const OUTPUT_PACKET_FRAMES: usize = OUTPUT_RATE as usize / PACKETS_PER_SECOND;
|
||||
const PACKET_COUNT: usize = 20;
|
||||
const PACKETS_PER_SECOND: usize = 100;
|
||||
const MIN_CONTINUITY_PACKETS: usize = 2;
|
||||
const TONE_FREQUENCY_HZ: f32 = 997.0;
|
||||
const TONE_AMPLITUDE: f32 = 0.5;
|
||||
const MAX_BOUNDARY_RESIDUAL: f32 = 0.02;
|
||||
const INCOMPLETE_SAMPLE_COUNT: usize = 1;
|
||||
const DOWNSAMPLE_RATE: u32 = 24_000;
|
||||
const REJECTED_TONE_HZ: f64 = 18_000.0;
|
||||
const MAX_ALIAS_RMS: f64 = 0.01;
|
||||
const MIN_PASSBAND_RMS: f64 = 0.3;
|
||||
|
||||
fn stereo_tone(frames: usize) -> Vec<f32> {
|
||||
(0..frames)
|
||||
.flat_map(|frame| {
|
||||
let phase =
|
||||
std::f32::consts::TAU * TONE_FREQUENCY_HZ * frame as f32 / INPUT_RATE as f32;
|
||||
let sample = TONE_AMPLITUDE * phase.sin();
|
||||
[sample, sample]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn maximum_boundary_residual(packets: &[Vec<f32>]) -> f32 {
|
||||
packets.windows(2).fold(0.0, |maximum, pair| {
|
||||
let previous = &pair[0];
|
||||
let current = &pair[1];
|
||||
let last = previous.len() - CHANNELS as usize;
|
||||
let penultimate = last - CHANNELS as usize;
|
||||
(0..CHANNELS as usize).fold(maximum, |maximum, channel| {
|
||||
let predicted = previous[last + channel]
|
||||
+ (previous[last + channel] - previous[penultimate + channel]);
|
||||
maximum.max((current[channel] - predicted).abs())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn stereo_config() -> AudioResamplerConfig {
|
||||
AudioResamplerConfig {
|
||||
input_rate: INPUT_RATE,
|
||||
output_rate: OUTPUT_RATE,
|
||||
channels: CHANNELS,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_capture_resampler_preserves_pending_audio() {
|
||||
let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT);
|
||||
let packet_samples = INPUT_PACKET_FRAMES * CHANNELS as usize;
|
||||
let mut expected_resampler =
|
||||
FixedFrameAudioResampler::new(stereo_config(), OUTPUT_PACKET_FRAMES).unwrap();
|
||||
let expected: Vec<_> = input
|
||||
.chunks(packet_samples)
|
||||
.flat_map(|packet| expected_resampler.process(packet).unwrap())
|
||||
.collect();
|
||||
let mut moved_resampler =
|
||||
FixedFrameAudioResampler::new(stereo_config(), OUTPUT_PACKET_FRAMES).unwrap();
|
||||
let mut output = moved_resampler.process(&input[..packet_samples]).unwrap();
|
||||
let remaining = std::thread::spawn(move || {
|
||||
input[packet_samples..]
|
||||
.chunks(packet_samples)
|
||||
.flat_map(|packet| moved_resampler.process(packet).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
output.extend(remaining);
|
||||
|
||||
assert!(output.len() >= MIN_CONTINUITY_PACKETS);
|
||||
assert!(output
|
||||
.iter()
|
||||
.all(|packet| packet.len() == OUTPUT_PACKET_FRAMES * CHANNELS as usize));
|
||||
assert!(maximum_boundary_residual(&output) <= MAX_BOUNDARY_RESIDUAL);
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
fn downsampled_rms(input: &[f32]) -> f64 {
|
||||
let config = AudioResamplerConfig {
|
||||
output_rate: DOWNSAMPLE_RATE,
|
||||
..stereo_config()
|
||||
};
|
||||
let output_frames = DOWNSAMPLE_RATE as usize / PACKETS_PER_SECOND;
|
||||
let mut resampler = FixedFrameAudioResampler::new(config, output_frames).unwrap();
|
||||
let output: Vec<f32> = input
|
||||
.chunks(INPUT_PACKET_FRAMES * CHANNELS as usize)
|
||||
.flat_map(|packet| resampler.process(packet).unwrap().into_iter().flatten())
|
||||
.collect();
|
||||
|
||||
assert!(output.len() >= output_frames * CHANNELS as usize * MIN_CONTINUITY_PACKETS);
|
||||
let mean_square = output
|
||||
.iter()
|
||||
.map(|sample| f64::from(*sample).powi(2))
|
||||
.sum::<f64>()
|
||||
/ output.len() as f64;
|
||||
mean_square.sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_downsampling_filters_out_of_band_audio() {
|
||||
let input: Vec<_> = (0..INPUT_PACKET_FRAMES * PACKET_COUNT)
|
||||
.flat_map(|frame| {
|
||||
let phase =
|
||||
std::f64::consts::TAU * REJECTED_TONE_HZ * frame as f64 / f64::from(INPUT_RATE);
|
||||
let sample = (f64::from(TONE_AMPLITUDE) * phase.sin()) as f32;
|
||||
[sample, sample]
|
||||
})
|
||||
.collect();
|
||||
let rms = downsampled_rms(&input);
|
||||
assert!(
|
||||
rms < MAX_ALIAS_RMS,
|
||||
"out-of-band output RMS {rms} exceeded {MAX_ALIAS_RMS}"
|
||||
);
|
||||
|
||||
let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT);
|
||||
let rms = downsampled_rms(&input);
|
||||
assert!(
|
||||
rms > MIN_PASSBAND_RMS,
|
||||
"in-band output RMS {rms} fell below {MIN_PASSBAND_RMS}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samplerate_backend_preserves_streaming_continuity() {
|
||||
let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT);
|
||||
let mut resampler = AudioResampler::new(stereo_config()).unwrap();
|
||||
let packets: Vec<_> = input
|
||||
.chunks(INPUT_PACKET_FRAMES * CHANNELS as usize)
|
||||
.map(|packet| resampler.process(packet).unwrap())
|
||||
.filter(|packet| !packet.is_empty())
|
||||
.collect();
|
||||
|
||||
assert!(packets.len() >= MIN_CONTINUITY_PACKETS);
|
||||
assert!(packets
|
||||
.iter()
|
||||
.all(|packet| packet.len() % CHANNELS as usize == 0));
|
||||
assert!(maximum_boundary_residual(&packets) <= MAX_BOUNDARY_RESIDUAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samplerate_backend_reports_incomplete_frame_context() {
|
||||
let mut resampler = AudioResampler::new(stereo_config()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resampler.process(&[0.0]).unwrap_err(),
|
||||
AudioResamplerError::IncompleteFrame {
|
||||
samples: INCOMPLETE_SAMPLE_COUNT,
|
||||
channels: CHANNELS as usize,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
use super::{AudioResamplerConfig, AudioResamplerError};
|
||||
use libsamplerate_sys as sys;
|
||||
use std::ptr::NonNull;
|
||||
|
||||
const OUTPUT_MARGIN_FRAMES: usize = 1;
|
||||
|
||||
pub(super) struct SincAudioResampler {
|
||||
state: NonNull<sys::SRC_STATE>,
|
||||
config: AudioResamplerConfig,
|
||||
}
|
||||
|
||||
impl SincAudioResampler {
|
||||
pub(super) fn new(config: AudioResamplerConfig) -> Result<Self, AudioResamplerError> {
|
||||
super::validate_config(config)?;
|
||||
let ratio = f64::from(config.output_rate) / f64::from(config.input_rate);
|
||||
if unsafe { sys::src_is_valid_ratio(ratio) } == 0 {
|
||||
return Err(backend_error(
|
||||
config,
|
||||
samplerate::ErrorCode::BadSrcRatio as _,
|
||||
));
|
||||
}
|
||||
let mut error = 0;
|
||||
// SAFETY: src_new allocates independent state; this owner releases it in Drop.
|
||||
let state = unsafe {
|
||||
sys::src_new(
|
||||
sys::SRC_SINC_BEST_QUALITY as _,
|
||||
config.channels.into(),
|
||||
&mut error,
|
||||
)
|
||||
};
|
||||
let state = NonNull::new(state).ok_or_else(|| backend_error(config, error))?;
|
||||
Ok(Self { state, config })
|
||||
}
|
||||
|
||||
pub(super) fn process_into(
|
||||
&mut self,
|
||||
input: &[f32],
|
||||
output: &mut Vec<f32>,
|
||||
) -> Result<(), AudioResamplerError> {
|
||||
super::validate_input(input, self.config.channels as usize)?;
|
||||
let mut consumed = 0;
|
||||
loop {
|
||||
let (used, generated) = self.process_block(&input[consumed..], output)?;
|
||||
consumed += used;
|
||||
if consumed == input.len() {
|
||||
return Ok(());
|
||||
}
|
||||
if used == 0 && generated == 0 {
|
||||
return Err(AudioResamplerError::Backend(
|
||||
"libsamplerate made no progress while input remained".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_block(
|
||||
&mut self,
|
||||
input: &[f32],
|
||||
output: &mut Vec<f32>,
|
||||
) -> Result<(usize, usize), AudioResamplerError> {
|
||||
let channels = self.config.channels as usize;
|
||||
let input_frames = input.len() / channels;
|
||||
let output_frames = input_frames
|
||||
.checked_mul(self.config.output_rate as usize)
|
||||
.map(|frames| frames / self.config.input_rate as usize)
|
||||
.and_then(|frames| frames.checked_add(OUTPUT_MARGIN_FRAMES))
|
||||
.ok_or(AudioResamplerError::CapacityOverflow)?;
|
||||
let start = output.len();
|
||||
let end = output_frames
|
||||
.checked_mul(channels)
|
||||
.and_then(|samples| start.checked_add(samples))
|
||||
.ok_or(AudioResamplerError::CapacityOverflow)?;
|
||||
let mut data = sys::SRC_DATA {
|
||||
data_in: input.as_ptr(),
|
||||
input_frames: input_frames
|
||||
.try_into()
|
||||
.map_err(|_| AudioResamplerError::CapacityOverflow)?,
|
||||
output_frames: output_frames
|
||||
.try_into()
|
||||
.map_err(|_| AudioResamplerError::CapacityOverflow)?,
|
||||
src_ratio: f64::from(self.config.output_rate) / f64::from(self.config.input_rate),
|
||||
..Default::default()
|
||||
};
|
||||
output.resize(end, 0.0);
|
||||
data.data_out = output[start..].as_mut_ptr();
|
||||
// SAFETY: state is exclusively owned; disjoint slices cover the declared frame counts.
|
||||
let error = unsafe { sys::src_process(self.state.as_ptr(), &mut data) };
|
||||
let generated = data.output_frames_gen as usize * channels;
|
||||
output.truncate(start + generated);
|
||||
if error != 0 {
|
||||
return Err(backend_error(self.config, error));
|
||||
}
|
||||
Ok((data.input_frames_used as usize * channels, generated))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SincAudioResampler {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: this owner holds the only handle returned by src_new.
|
||||
unsafe { sys::src_delete(self.state.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_error(config: AudioResamplerConfig, code: i32) -> AudioResamplerError {
|
||||
AudioResamplerError::Backend(format!(
|
||||
"input_rate={}, output_rate={}, channels={}: {:?}",
|
||||
config.input_rate,
|
||||
config.output_rate,
|
||||
config.channels,
|
||||
samplerate::Error::from_int(code)
|
||||
))
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
use super::{AudioResampler, AudioResamplerConfig, FixedFrameAudioResampler};
|
||||
|
||||
const INPUT_RATE: u32 = 24_000;
|
||||
const OUTPUT_RATE: u32 = 48_000;
|
||||
const CHANNELS: u16 = 2;
|
||||
const CHUNK_FRAMES: usize = 240;
|
||||
const CHUNK_COUNT: usize = 4;
|
||||
const TONE_FREQUENCY_HZ: f32 = 997.0;
|
||||
const TONE_AMPLITUDE: f32 = 0.5;
|
||||
const MAX_BOUNDARY_RESIDUAL: f32 = 0.02;
|
||||
const LOOK_AHEAD_OUTPUT_FRAMES: usize = 1;
|
||||
const UNEVEN_CHUNK_FRAMES: usize = 73;
|
||||
const MONO_CHANNELS: u16 = 1;
|
||||
const UNIT_RATE: u32 = 1;
|
||||
const DOUBLE_RATE: u32 = 2;
|
||||
const FIRST_DOWNSAMPLE_PACKET: [f32; 3] = [0.0, 1.0, 2.0];
|
||||
const SECOND_DOWNSAMPLE_PACKET: [f32; 4] = [3.0, 4.0, 5.0, 6.0];
|
||||
const EXPECTED_DOWNSAMPLED_OUTPUT: [f32; 4] = [0.0, 2.0, 4.0, 6.0];
|
||||
const PACKETS_PER_SECOND: usize = 100;
|
||||
const OUTPUT_PACKET_FRAMES: usize = OUTPUT_RATE as usize / PACKETS_PER_SECOND;
|
||||
const RATE_44_1_KHZ: u32 = 44_100;
|
||||
const FLOAT_TOLERANCE: f32 = 0.000_001;
|
||||
const MIN_CONTINUITY_PACKETS: usize = 2;
|
||||
|
||||
fn stereo_tone_at_rate(frames: usize, sample_rate: u32) -> Vec<f32> {
|
||||
(0..frames)
|
||||
.flat_map(|frame| {
|
||||
let phase =
|
||||
std::f32::consts::TAU * TONE_FREQUENCY_HZ * frame as f32 / sample_rate as f32;
|
||||
let sample = TONE_AMPLITUDE * phase.sin();
|
||||
[sample, sample]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn stereo_tone(frames: usize) -> Vec<f32> {
|
||||
stereo_tone_at_rate(frames, INPUT_RATE)
|
||||
}
|
||||
|
||||
fn maximum_tone_prediction_residual(sample_rate: u32) -> f32 {
|
||||
let half_step = std::f32::consts::PI * TONE_FREQUENCY_HZ / sample_rate as f32;
|
||||
4.0 * TONE_AMPLITUDE * half_step.sin().powi(2)
|
||||
}
|
||||
|
||||
fn maximum_boundary_residual(chunks: &[Vec<f32>]) -> f32 {
|
||||
chunks.windows(2).fold(0.0, |maximum, pair| {
|
||||
let previous = &pair[0];
|
||||
let current = &pair[1];
|
||||
let last = previous.len() - CHANNELS as usize;
|
||||
let penultimate = last - CHANNELS as usize;
|
||||
(0..CHANNELS as usize).fold(maximum, |maximum, channel| {
|
||||
let predicted = previous[last + channel]
|
||||
+ (previous[last + channel] - previous[penultimate + channel]);
|
||||
maximum.max((current[channel] - predicted).abs())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn stereo_config() -> AudioResamplerConfig {
|
||||
AudioResamplerConfig {
|
||||
input_rate: INPUT_RATE,
|
||||
output_rate: OUTPUT_RATE,
|
||||
channels: CHANNELS,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_decoded_packet_continuity_and_output_ratio() {
|
||||
let input = stereo_tone(CHUNK_FRAMES * CHUNK_COUNT);
|
||||
let mut whole_resampler = AudioResampler::new(stereo_config()).unwrap();
|
||||
let whole_output = whole_resampler.process(&input).unwrap();
|
||||
let expected_frames = CHUNK_FRAMES * CHUNK_COUNT * OUTPUT_RATE as usize / INPUT_RATE as usize
|
||||
- LOOK_AHEAD_OUTPUT_FRAMES;
|
||||
|
||||
for chunk_frames in [CHUNK_FRAMES, UNEVEN_CHUNK_FRAMES] {
|
||||
let mut resampler = AudioResampler::new(stereo_config()).unwrap();
|
||||
let output: Vec<_> = input
|
||||
.chunks(chunk_frames * CHANNELS as usize)
|
||||
.map(|chunk| resampler.process(chunk).unwrap())
|
||||
.collect();
|
||||
let residual = maximum_boundary_residual(&output);
|
||||
assert!(
|
||||
residual <= MAX_BOUNDARY_RESIDUAL,
|
||||
"packet boundary residual {residual} exceeded {MAX_BOUNDARY_RESIDUAL}, chunk_frames={chunk_frames}"
|
||||
);
|
||||
let output_frames = output.iter().map(Vec::len).sum::<usize>() / CHANNELS as usize;
|
||||
assert_eq!(output_frames, expected_frames);
|
||||
assert_eq!(output.concat(), whole_output, "chunk_frames={chunk_frames}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_interleaved_frames() {
|
||||
let mut resampler = AudioResampler::new(stereo_config()).unwrap();
|
||||
|
||||
assert!(resampler.process(&[TONE_AMPLITUDE]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolates_mono_samples() {
|
||||
let config = AudioResamplerConfig {
|
||||
input_rate: UNIT_RATE,
|
||||
output_rate: DOUBLE_RATE,
|
||||
channels: MONO_CHANNELS,
|
||||
};
|
||||
let mut resampler = AudioResampler::new(config).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resampler.process(&[0.0, 1.0, 2.0]).unwrap(),
|
||||
[0.0, 0.5, 1.0, 1.5, 2.0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_rate_configuration() {
|
||||
let config = AudioResamplerConfig {
|
||||
input_rate: 0,
|
||||
output_rate: OUTPUT_RATE,
|
||||
channels: CHANNELS,
|
||||
};
|
||||
|
||||
assert!(AudioResampler::new(config).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsamples_across_packet_boundaries() {
|
||||
let config = AudioResamplerConfig {
|
||||
input_rate: DOUBLE_RATE,
|
||||
output_rate: UNIT_RATE,
|
||||
channels: MONO_CHANNELS,
|
||||
};
|
||||
let mut resampler = AudioResampler::new(config).unwrap();
|
||||
let mut output = resampler.process(&FIRST_DOWNSAMPLE_PACKET).unwrap();
|
||||
output.extend(resampler.process(&SECOND_DOWNSAMPLE_PACKET).unwrap());
|
||||
|
||||
assert_eq!(output, EXPECTED_DOWNSAMPLED_OUTPUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sender_resampler_emits_only_complete_continuous_frames() {
|
||||
let input = stereo_tone(CHUNK_FRAMES * CHUNK_COUNT);
|
||||
let mut resampler =
|
||||
FixedFrameAudioResampler::new(stereo_config(), OUTPUT_PACKET_FRAMES).unwrap();
|
||||
let output: Vec<_> = input
|
||||
.chunks(CHUNK_FRAMES * CHANNELS as usize)
|
||||
.flat_map(|chunk| resampler.process(chunk).unwrap())
|
||||
.collect();
|
||||
|
||||
assert!(output.len() >= MIN_CONTINUITY_PACKETS);
|
||||
assert!(output
|
||||
.iter()
|
||||
.all(|packet| packet.len() == OUTPUT_PACKET_FRAMES * CHANNELS as usize));
|
||||
assert!(maximum_boundary_residual(&output) <= MAX_BOUNDARY_RESIDUAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sender_downsampling_preserves_packet_continuity() {
|
||||
let input_packet_frames = RATE_44_1_KHZ as usize / PACKETS_PER_SECOND;
|
||||
let input = stereo_tone_at_rate(input_packet_frames * CHUNK_COUNT, RATE_44_1_KHZ);
|
||||
let config = AudioResamplerConfig {
|
||||
input_rate: RATE_44_1_KHZ,
|
||||
output_rate: INPUT_RATE,
|
||||
channels: CHANNELS,
|
||||
};
|
||||
let mut resampler =
|
||||
FixedFrameAudioResampler::new(config, INPUT_RATE as usize / PACKETS_PER_SECOND).unwrap();
|
||||
let packets: Vec<_> = input
|
||||
.chunks(input_packet_frames * CHANNELS as usize)
|
||||
.flat_map(|packet| resampler.process(packet).unwrap())
|
||||
.collect();
|
||||
let residual = maximum_boundary_residual(&packets);
|
||||
|
||||
assert_eq!(packets.len(), CHUNK_COUNT);
|
||||
assert!(
|
||||
residual <= maximum_tone_prediction_residual(INPUT_RATE) + FLOAT_TOLERANCE,
|
||||
"sender packet boundary residual {residual} exceeded the tone curvature"
|
||||
);
|
||||
}
|
||||
398
src/client.rs
398
src/client.rs
@@ -30,8 +30,7 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
check_port,
|
||||
common::input::{MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP},
|
||||
create_symmetric_key_msg, decode_id_pk, decode_id_pk_dtls, dtls_fingerprint_bound, get_rs_pk,
|
||||
is_keyboard_mode_supported,
|
||||
create_symmetric_key_msg, decode_id_pk, decode_id_pk_dtls, get_rs_pk, is_keyboard_mode_supported,
|
||||
kcp_stream::KcpStream,
|
||||
secure_tcp,
|
||||
ui_interface::{get_builtin_option, resolve_avatar_url, use_texture_render},
|
||||
@@ -94,13 +93,6 @@ use crate::ui_session_interface::SessionPermissionConfig;
|
||||
|
||||
pub use super::lang::*;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod audio_playback;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod audio_playback_recovery;
|
||||
#[cfg(all(test, not(target_os = "linux")))]
|
||||
#[path = "client/tests/audio_state_tests.rs"]
|
||||
mod audio_state_tests;
|
||||
pub mod file_trait;
|
||||
pub mod helper;
|
||||
pub mod io_loop;
|
||||
@@ -577,7 +569,7 @@ impl Client {
|
||||
return race_transports_prefer_webrtc(
|
||||
preferred_fut,
|
||||
vec![fallback_fut],
|
||||
Self::relay_fallback_delay_ms(),
|
||||
Self::WEBRTC_PREFER_WINDOW_MS,
|
||||
|result| result.0 .1,
|
||||
)
|
||||
.await;
|
||||
@@ -618,27 +610,11 @@ impl Client {
|
||||
/// ones that traverse NAT.
|
||||
const MAX_PENDING_WEBRTC_ICE: usize = 64;
|
||||
|
||||
/// Default relay fallback delay: how long an already-established relay result is held back
|
||||
/// while a WebRTC attempt is still in flight, and the floor for a punch-path WebRTC attempt
|
||||
/// whose race timeout is tuned for a raw TCP SYN. Long enough for candidate trickle + ICE
|
||||
/// checks + DTLS on high-latency links; short enough that UDP-blocked networks settle on
|
||||
/// relay without a noticeable wait. The same role RFC 8305 calls a connection attempt delay.
|
||||
const RELAY_FALLBACK_DELAY_MS: u64 = 2500;
|
||||
|
||||
/// The delay as the user configured it, falling back to `RELAY_FALLBACK_DELAY_MS`. The
|
||||
/// settings field holds seconds, which is what a user reasons about; everything here is
|
||||
/// milliseconds. Unparseable, zero or negative all mean "unset", so clearing the field
|
||||
/// restores the default instead of collapsing the delay and handing every race to the
|
||||
/// relay.
|
||||
fn relay_fallback_delay_ms() -> u64 {
|
||||
match LocalConfig::get_option(keys::OPTION_RELAY_FALLBACK_DELAY)
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
{
|
||||
Ok(secs) if secs.is_finite() && secs > 0.0 => (secs * 1000.0).round() as u64,
|
||||
_ => Self::RELAY_FALLBACK_DELAY_MS,
|
||||
}
|
||||
}
|
||||
/// Prefer-P2P window: how long a WebRTC attempt outranks an already-established relay
|
||||
/// result, and the floor for a punch-path WebRTC attempt whose race timeout is tuned for a
|
||||
/// raw TCP SYN. Long enough for candidate trickle + ICE checks + DTLS on high-latency
|
||||
/// links; short enough that UDP-blocked networks settle on relay without a noticeable wait.
|
||||
const WEBRTC_PREFER_WINDOW_MS: u64 = 2500;
|
||||
|
||||
/// UDP-NAT-test wait when the TCP clock is implausible (see TCP_RTT_PLAUSIBLE_MIN). The
|
||||
/// normal bound is `rtt / 2`: the test has been running since before the TCP connect, so on
|
||||
@@ -1138,7 +1114,7 @@ impl Client {
|
||||
race_transports_prefer_webrtc(
|
||||
webrtc_fut,
|
||||
connect_futures,
|
||||
Self::relay_fallback_delay_ms(),
|
||||
Self::WEBRTC_PREFER_WINDOW_MS,
|
||||
|result| result.3,
|
||||
)
|
||||
.await
|
||||
@@ -1466,7 +1442,7 @@ impl Client {
|
||||
// so a viable P2P path is not abandoned before it can complete; TCP/UDP keep the
|
||||
// tighter timeout, so a working direct connection still wins immediately, and the
|
||||
// relay fallback only waits the extra time when direct attempts all failed.
|
||||
let webrtc_timeout = connect_timeout.max(Self::relay_fallback_delay_ms());
|
||||
let webrtc_timeout = connect_timeout.max(Self::WEBRTC_PREFER_WINDOW_MS);
|
||||
async move {
|
||||
raced.wait_connected(webrtc_timeout).await?;
|
||||
// Resolve the pair here: a TURN win is relayed, not direct, and must be held
|
||||
@@ -1484,7 +1460,7 @@ impl Client {
|
||||
race_transports_prefer_webrtc(
|
||||
webrtc_fut,
|
||||
direct_futures,
|
||||
Self::relay_fallback_delay_ms(),
|
||||
Self::WEBRTC_PREFER_WINDOW_MS,
|
||||
|r| r.3,
|
||||
)
|
||||
.await
|
||||
@@ -1672,7 +1648,7 @@ impl Client {
|
||||
let actual_fp = conn.dtls_fingerprint(false).await.ok_or_else(
|
||||
|| anyhow!("WebRTC DTLS fingerprint unavailable"),
|
||||
)?;
|
||||
if !dtls_fingerprint_bound(&signed_fp, &actual_fp) {
|
||||
if signed_fp.is_empty() || signed_fp != actual_fp {
|
||||
bail!("WebRTC DTLS fingerprint not bound to peer identity (possible MITM)");
|
||||
}
|
||||
}
|
||||
@@ -2077,8 +2053,6 @@ pub struct AudioHandler {
|
||||
simple: Option<psimple::Simple>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
audio_buffer: AudioBuffer,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
audio_resampler: Option<crate::audio_resampler::AudioResampler>,
|
||||
sample_rate: (u32, u32),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
audio_stream: Option<Box<dyn StreamTrait>>,
|
||||
@@ -2086,57 +2060,7 @@ pub struct AudioHandler {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
device_channel: u16,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
playback_status: Arc<audio_playback::AudioPlaybackStatus>,
|
||||
#[cfg(target_os = "windows")]
|
||||
playback_recovery: audio_playback_recovery::PlaybackRecovery,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[derive(Clone, Copy)]
|
||||
struct DecodedAudioConfig {
|
||||
sample_rate: u32,
|
||||
input_channels: u16,
|
||||
output_channels: u16,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn create_audio_resampler(
|
||||
input_rate: u32,
|
||||
output_rate: u32,
|
||||
channels: u16,
|
||||
) -> ResultType<Option<crate::audio_resampler::AudioResampler>> {
|
||||
if input_rate == output_rate {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(crate::audio_resampler::AudioResampler::new(
|
||||
crate::audio_resampler::AudioResamplerConfig {
|
||||
input_rate,
|
||||
output_rate,
|
||||
channels,
|
||||
},
|
||||
)?))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn prepare_decoded_audio(
|
||||
input: &[f32],
|
||||
resampler: Option<&mut crate::audio_resampler::AudioResampler>,
|
||||
config: DecodedAudioConfig,
|
||||
) -> Result<Vec<f32>, crate::audio_resampler::AudioResamplerError> {
|
||||
let mut output = match resampler {
|
||||
Some(resampler) => resampler.process(input)?,
|
||||
None => input.to_owned(),
|
||||
};
|
||||
if config.input_channels != config.output_channels {
|
||||
output = crate::audio_rechannel(
|
||||
output,
|
||||
config.sample_rate,
|
||||
config.sample_rate,
|
||||
config.input_channels,
|
||||
config.output_channels,
|
||||
);
|
||||
}
|
||||
Ok(output)
|
||||
ready: Arc<std::sync::Mutex<bool>>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -2144,7 +2068,6 @@ struct AudioBuffer(
|
||||
pub Arc<std::sync::Mutex<ringbuf::HeapRb<f32>>>,
|
||||
usize,
|
||||
[usize; 30],
|
||||
Arc<std::sync::atomic::AtomicUsize>,
|
||||
);
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -2156,7 +2079,6 @@ impl Default for AudioBuffer {
|
||||
)),
|
||||
48000 * 2,
|
||||
[0; 30],
|
||||
Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2231,38 +2153,27 @@ impl AudioBuffer {
|
||||
let skip = (cap * max / (30 * N) + 1) & (!1);
|
||||
if (having > skip * 3) && (skip > 0) {
|
||||
lock.skip(skip);
|
||||
let generation = self.signal_discontinuity();
|
||||
drop(lock);
|
||||
log::info!("skip {skip}, based {max} {zero}, generation={generation}");
|
||||
log::info!("skip {skip}, based {max} {zero}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The caller must hold the PCM buffer lock while signaling the discard.
|
||||
fn signal_discontinuity(&self) -> usize {
|
||||
self.3
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
.wrapping_add(1)
|
||||
}
|
||||
|
||||
/// append pcm to audio buffer, if buffered data
|
||||
/// exceeds AUDIO_BUFFER_MS, only AUDIO_BUFFER_MS
|
||||
/// will be kept.
|
||||
fn append_pcm2(&self, buffer: &[f32]) -> usize {
|
||||
let mut lock = self.0.lock().unwrap();
|
||||
let cap = lock.capacity();
|
||||
let having = lock.occupied_len() + buffer.len();
|
||||
lock.push_slice_overwrite(buffer);
|
||||
let discard = (having > cap).then(|| (having - cap, self.signal_discontinuity()));
|
||||
let occupied = lock.occupied_len();
|
||||
drop(lock);
|
||||
if let Some((discarded, generation)) = discard {
|
||||
hbb_common::throttled_log!(
|
||||
audio_playback::AUDIO_PLAYBACK_LOG_INTERVAL,
|
||||
debug,
|
||||
"Audio buffer capacity discard: samples={discarded}, generation={generation}"
|
||||
);
|
||||
if buffer.len() > cap {
|
||||
lock.push_slice_overwrite(buffer);
|
||||
return cap;
|
||||
}
|
||||
occupied
|
||||
|
||||
let having = lock.occupied_len() + buffer.len();
|
||||
if having > cap {
|
||||
lock.skip(having - cap);
|
||||
}
|
||||
lock.push_slice_overwrite(buffer);
|
||||
lock.occupied_len()
|
||||
}
|
||||
|
||||
/// append pcm to audio buffer, trying to drop data
|
||||
@@ -2274,41 +2185,6 @@ impl AudioBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_os = "linux")))]
|
||||
mod audio_buffer_discontinuity_tests {
|
||||
use super::AudioBuffer;
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc, Mutex,
|
||||
};
|
||||
|
||||
const BUFFER_CAPACITY: usize = 4;
|
||||
const BUFFER_LEVELS: usize = 30;
|
||||
const FIRST_INPUT: [f32; 2] = [0.1, 0.2];
|
||||
const OVERFLOWING_INPUT: [f32; 3] = [0.3, 0.4, 0.5];
|
||||
const OVERSIZED_INPUT: [f32; 5] = [0.6, 0.7, 0.8, 0.9, 1.0];
|
||||
|
||||
#[test]
|
||||
fn capacity_discards_signal_discontinuities() {
|
||||
let audio_buffer = AudioBuffer(
|
||||
Arc::new(Mutex::new(ringbuf::HeapRb::new(BUFFER_CAPACITY))),
|
||||
BUFFER_CAPACITY,
|
||||
[0; BUFFER_LEVELS],
|
||||
Arc::new(AtomicUsize::new(0)),
|
||||
);
|
||||
|
||||
assert_eq!(audio_buffer.append_pcm2(&FIRST_INPUT), FIRST_INPUT.len());
|
||||
assert_eq!(audio_buffer.3.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(
|
||||
audio_buffer.append_pcm2(&OVERFLOWING_INPUT),
|
||||
BUFFER_CAPACITY
|
||||
);
|
||||
assert_eq!(audio_buffer.3.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(audio_buffer.append_pcm2(&OVERSIZED_INPUT), BUFFER_CAPACITY);
|
||||
assert_eq!(audio_buffer.3.load(Ordering::Relaxed), 2);
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioHandler {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn start_audio(&mut self, format0: AudioFormat) -> ResultType<()> {
|
||||
@@ -2355,16 +2231,13 @@ impl AudioHandler {
|
||||
log::info!("Remote input format: {:?}", format0);
|
||||
#[allow(unused_mut)]
|
||||
let mut config: StreamConfig = config.into();
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
{
|
||||
// this makes ios and android audio output not work
|
||||
// this makes ios audio output not work
|
||||
config.buffer_size = cpal::BufferSize::Fixed(64);
|
||||
}
|
||||
|
||||
self.sample_rate = (format0.sample_rate, config.sample_rate.0);
|
||||
let audio_resampler = create_audio_resampler(
|
||||
format0.sample_rate, config.sample_rate.0, format0.channels as _,
|
||||
)?;
|
||||
let mut build_output_stream = |config: StreamConfig| match sample_format {
|
||||
cpal::SampleFormat::I8 => self.build_output_stream::<i8>(&config, &device),
|
||||
cpal::SampleFormat::I16 => self.build_output_stream::<i16>(&config, &device),
|
||||
@@ -2389,61 +2262,22 @@ impl AudioHandler {
|
||||
} else {
|
||||
build_output_stream(config)?;
|
||||
}
|
||||
self.audio_resampler = audio_resampler;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle audio format and create an audio decoder.
|
||||
pub fn handle_format(&mut self, f: AudioFormat) {
|
||||
self.handle_format_with_start(f, Self::start_audio);
|
||||
}
|
||||
|
||||
fn handle_format_with_start(
|
||||
&mut self,
|
||||
f: AudioFormat,
|
||||
start: impl FnOnce(&mut Self, AudioFormat) -> ResultType<()>,
|
||||
) {
|
||||
if !is_supported_audio_channel_count(f.channels) {
|
||||
log::error!("Unsupported audio channel count: {}", f.channels);
|
||||
return;
|
||||
}
|
||||
match AudioDecoder::new(f.sample_rate, if f.channels > 1 { Stereo } else { Mono }) {
|
||||
Ok(d) => {
|
||||
#[cfg(target_os = "windows")]
|
||||
let playback_failed = self.cancel_pending_playback();
|
||||
#[cfg(target_os = "linux")]
|
||||
let keep_existing_stream = self.simple.is_some()
|
||||
&& self.sample_rate.0 == f.sample_rate
|
||||
&& u32::from(self.channels) == f.channels;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let keep_existing_stream = self.audio_stream.is_some()
|
||||
&& self.sample_rate.0 == f.sample_rate
|
||||
&& u32::from(self.channels) == f.channels;
|
||||
let buffer = vec![0.; f.sample_rate as usize * f.channels as usize];
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let mut previous = std::mem::take(self);
|
||||
#[cfg(target_os = "windows")]
|
||||
self.prepare_playback(&f);
|
||||
self.audio_decoder = Some((d, buffer));
|
||||
self.channels = f.channels as _;
|
||||
let result = start(self, f);
|
||||
#[cfg(target_os = "windows")]
|
||||
let keep_existing_stream = keep_existing_stream
|
||||
&& !playback_failed
|
||||
&& !previous.playback_recovery.report_pending();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if result.is_err() && keep_existing_stream {
|
||||
// The restarted capture has new Opus history even when output startup fails.
|
||||
previous.audio_decoder = self.audio_decoder.take();
|
||||
*self = previous;
|
||||
self.handle_audio_start_result(result, true);
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
self.finish_playback_replacement(result, keep_existing_stream.then_some(previous));
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
self.handle_audio_start_result(result, keep_existing_stream);
|
||||
allow_err!(self.start_audio(f));
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to create audio decoder: {}", err);
|
||||
@@ -2451,73 +2285,52 @@ impl AudioHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_audio_start_result(&mut self, result: ResultType<()>, keep_existing_stream: bool) {
|
||||
if let Err(error) = result {
|
||||
if keep_existing_stream {
|
||||
log::error!(
|
||||
"Failed to replace audio playback stream; keeping the existing compatible stream: {error:#}"
|
||||
);
|
||||
} else {
|
||||
*self = Self::default();
|
||||
log::error!("Failed to start audio playback: {error:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle audio frame and play it.
|
||||
#[inline]
|
||||
pub fn handle_frame(&mut self, frame: AudioFrame) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
self.playback_status.report_errors();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if self.audio_stream.is_none()
|
||||
|| !self
|
||||
.playback_status
|
||||
.ready
|
||||
.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
if self.audio_stream.is_none() || !self.ready.lock().unwrap().clone() {
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if self.simple.is_none() {
|
||||
log::trace!("PulseAudio simple binding does not exists");
|
||||
log::debug!("PulseAudio simple binding does not exists");
|
||||
return;
|
||||
}
|
||||
self.audio_decoder.as_mut().map(|(d, buffer)| {
|
||||
let decoded_frames = match d.decode_float(&frame.data, buffer, false) {
|
||||
Ok(decoded_frames) => decoded_frames,
|
||||
Err(error) => {
|
||||
log::warn!("Failed to decode audio frame: {error:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let channels = self.channels;
|
||||
let n = decoded_frames * channels as usize;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let config = DecodedAudioConfig {
|
||||
sample_rate: self.sample_rate.1,
|
||||
input_channels: self.channels,
|
||||
output_channels: self.device_channel,
|
||||
};
|
||||
let buffer = match prepare_decoded_audio(
|
||||
&buffer[0..n],
|
||||
self.audio_resampler.as_mut(),
|
||||
config,
|
||||
) {
|
||||
Ok(output) => output,
|
||||
Err(error) => {
|
||||
log::error!("Failed to resample decoded audio: {error:#}");
|
||||
return;
|
||||
if let Ok(n) = d.decode_float(&frame.data, buffer, false) {
|
||||
let channels = self.channels;
|
||||
let n = n * (channels as usize);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let sample_rate0 = self.sample_rate.0;
|
||||
let sample_rate = self.sample_rate.1;
|
||||
let mut buffer = buffer[0..n].to_owned();
|
||||
if sample_rate != sample_rate0 {
|
||||
buffer = crate::audio_resample(
|
||||
&buffer[0..n],
|
||||
sample_rate0,
|
||||
sample_rate,
|
||||
channels,
|
||||
);
|
||||
}
|
||||
};
|
||||
self.audio_buffer.append_pcm(&buffer);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let data_u8 =
|
||||
unsafe { std::slice::from_raw_parts::<u8>(buffer.as_ptr() as _, n * 4) };
|
||||
self.simple.as_mut().map(|x| x.write(data_u8));
|
||||
if self.channels != self.device_channel {
|
||||
buffer = crate::audio_rechannel(
|
||||
buffer,
|
||||
sample_rate,
|
||||
sample_rate,
|
||||
self.channels,
|
||||
self.device_channel,
|
||||
);
|
||||
}
|
||||
self.audio_buffer.append_pcm(&buffer);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let data_u8 =
|
||||
unsafe { std::slice::from_raw_parts::<u8>(buffer.as_ptr() as _, n * 4) };
|
||||
self.simple.as_mut().map(|x| x.write(data_u8));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2530,9 +2343,6 @@ impl AudioHandler {
|
||||
device: &Device,
|
||||
) -> ResultType<()> {
|
||||
self.device_channel = config.channels;
|
||||
#[cfg(target_os = "windows")]
|
||||
let err_fn = self.playback_recovery.new_error_callback();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let err_fn = move |err| {
|
||||
// too many errors, will improve later
|
||||
log::trace!("an error occurred on stream: {}", err);
|
||||
@@ -2540,28 +2350,63 @@ impl AudioHandler {
|
||||
self.audio_buffer
|
||||
.resize(config.sample_rate.0 as _, config.channels as _);
|
||||
let audio_buffer = self.audio_buffer.0.clone();
|
||||
let discontinuity_generation = self.audio_buffer.3.clone();
|
||||
let mut playback_writer = audio_playback::AudioPlaybackWriter::new(
|
||||
audio_playback::AudioPlaybackConfig {
|
||||
sample_rate: config.sample_rate.0,
|
||||
channels: config.channels as usize,
|
||||
},
|
||||
audio_buffer,
|
||||
discontinuity_generation,
|
||||
)?;
|
||||
let playback_status = playback_writer.status.clone();
|
||||
let ready = self.ready.clone();
|
||||
let timeout = None;
|
||||
let stream = device.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||
playback_writer.write_output(data);
|
||||
move |data: &mut [T], info: &cpal::OutputCallbackInfo| {
|
||||
if !*ready.lock().unwrap() {
|
||||
*ready.lock().unwrap() = true;
|
||||
}
|
||||
|
||||
let mut n = data.len();
|
||||
let mut lock = audio_buffer.lock().unwrap();
|
||||
let mut having = lock.occupied_len();
|
||||
// android two timestamps, one from zero, another not
|
||||
#[cfg(not(target_os = "android"))]
|
||||
if having < n {
|
||||
let tms = info.timestamp();
|
||||
let how_long = tms
|
||||
.playback
|
||||
.duration_since(&tms.callback)
|
||||
.unwrap_or(Duration::from_millis(0));
|
||||
|
||||
// must long enough to fight back scheuler delay
|
||||
if how_long > Duration::from_millis(6) && how_long < Duration::from_millis(3000)
|
||||
{
|
||||
drop(lock);
|
||||
std::thread::sleep(how_long.div_f32(1.2));
|
||||
lock = audio_buffer.lock().unwrap();
|
||||
having = lock.occupied_len();
|
||||
}
|
||||
|
||||
if having < n {
|
||||
n = having;
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
if having < n {
|
||||
n = having;
|
||||
}
|
||||
let mut elems = vec![0.0f32; n];
|
||||
if n > 0 {
|
||||
lock.pop_slice(&mut elems);
|
||||
}
|
||||
drop(lock);
|
||||
|
||||
let mut input = elems.into_iter();
|
||||
for sample in data.iter_mut() {
|
||||
*sample = match input.next() {
|
||||
Some(x) => T::from_sample(x),
|
||||
_ => T::from_sample(0.),
|
||||
};
|
||||
}
|
||||
},
|
||||
err_fn,
|
||||
timeout,
|
||||
)?;
|
||||
stream.play()?;
|
||||
self.audio_stream = Some(Box::new(stream));
|
||||
self.playback_status = playback_status;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2581,27 +2426,6 @@ mod audio_format_tests {
|
||||
assert!(!is_supported_audio_channel_count(0));
|
||||
assert!(!is_supported_audio_channel_count(u32::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_audio_start_discards_format_state() {
|
||||
use super::{anyhow, AudioDecoder, AudioHandler, Stereo};
|
||||
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
const CHANNELS: u16 = 2;
|
||||
let decoder = AudioDecoder::new(SAMPLE_RATE, Stereo).unwrap();
|
||||
let mut handler = AudioHandler {
|
||||
audio_decoder: Some((decoder, Vec::new())),
|
||||
sample_rate: (SAMPLE_RATE, SAMPLE_RATE),
|
||||
channels: CHANNELS,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
handler.handle_audio_start_result(Err(anyhow!("Injected playback startup failure")), false);
|
||||
|
||||
assert!(handler.audio_decoder.is_none());
|
||||
assert_eq!(handler.channels, 0);
|
||||
assert_eq!(handler.sample_rate, (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Video handler for the [`Client`].
|
||||
@@ -4105,11 +3929,7 @@ pub fn start_audio_thread() -> MediaSender {
|
||||
std::thread::spawn(move || {
|
||||
let mut audio_handler = AudioHandler::default();
|
||||
loop {
|
||||
#[cfg(target_os = "windows")]
|
||||
let received = audio_handler.receive_audio(&audio_receiver);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let received = audio_receiver.recv();
|
||||
if let Ok(data) = received {
|
||||
if let Ok(data) = audio_receiver.recv() {
|
||||
match data {
|
||||
MediaData::AudioFrame(af) => {
|
||||
audio_handler.handle_frame(*af);
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
use hbb_common::{log, log_throttle::LogThrottle, thiserror};
|
||||
use ringbuf::{ring_buffer::RbBase, Rb};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
TryLockError,
|
||||
};
|
||||
|
||||
pub(super) const UNDERRUN_DECLICK_MS: usize = 5;
|
||||
pub(super) const AUDIO_PLAYBACK_LOG_INTERVAL: std::time::Duration =
|
||||
std::time::Duration::from_secs(5);
|
||||
const MILLISECONDS_PER_SECOND: usize = 1_000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct AudioPlaybackConfig {
|
||||
pub sample_rate: u32,
|
||||
pub channels: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub(super) enum AudioPlaybackError {
|
||||
#[error(
|
||||
"invalid audio playback configuration: sample_rate={}, channels={}",
|
||||
.0.sample_rate, .0.channels
|
||||
)]
|
||||
InvalidConfig(AudioPlaybackConfig),
|
||||
#[error("audio playback frame has {samples} samples for {channels} channels")]
|
||||
IncompleteFrame { samples: usize, channels: usize },
|
||||
#[error("audio playback transition frame count overflow")]
|
||||
FrameCountOverflow,
|
||||
}
|
||||
|
||||
pub(super) struct AudioPlaybackRecovery {
|
||||
channels: usize,
|
||||
transition_frames: usize,
|
||||
transition_frame: usize,
|
||||
had_input: bool,
|
||||
transition_start: Vec<f32>,
|
||||
output_frame: Vec<f32>,
|
||||
}
|
||||
|
||||
pub(super) struct AudioPlaybackStatus {
|
||||
pub(super) ready: AtomicBool,
|
||||
contentions: AtomicUsize,
|
||||
contention_log_throttle: LogThrottle,
|
||||
buffer_poisoned: AtomicBool,
|
||||
}
|
||||
|
||||
impl Default for AudioPlaybackStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ready: AtomicBool::new(false),
|
||||
contentions: AtomicUsize::new(0),
|
||||
contention_log_throttle: LogThrottle::new(AUDIO_PLAYBACK_LOG_INTERVAL),
|
||||
buffer_poisoned: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioPlaybackStatus {
|
||||
pub(super) fn report_errors(&self) {
|
||||
if self.contentions.load(Ordering::Relaxed) != 0
|
||||
&& self.contention_log_throttle.due().is_some()
|
||||
{
|
||||
let contentions = self.contentions.swap(0, Ordering::Relaxed);
|
||||
log::debug!("Audio playback PCM buffer contention: callbacks={contentions}");
|
||||
}
|
||||
if self.buffer_poisoned.swap(false, Ordering::Relaxed) {
|
||||
log::error!("Audio playback stopped reading a poisoned PCM buffer");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct AudioPlaybackWriter {
|
||||
audio_buffer: std::sync::Arc<std::sync::Mutex<ringbuf::HeapRb<f32>>>,
|
||||
discontinuity_generation: std::sync::Arc<AtomicUsize>,
|
||||
observed_discontinuity_generation: usize,
|
||||
buffered_input: Vec<f32>,
|
||||
recovery: AudioPlaybackRecovery,
|
||||
pub(super) status: std::sync::Arc<AudioPlaybackStatus>,
|
||||
buffer_failed: bool,
|
||||
}
|
||||
|
||||
impl AudioPlaybackWriter {
|
||||
pub(super) fn new(
|
||||
config: AudioPlaybackConfig,
|
||||
audio_buffer: std::sync::Arc<std::sync::Mutex<ringbuf::HeapRb<f32>>>,
|
||||
discontinuity_generation: std::sync::Arc<AtomicUsize>,
|
||||
) -> Result<Self, AudioPlaybackError> {
|
||||
let recovery = AudioPlaybackRecovery::new(config)?;
|
||||
let buffer_capacity = audio_buffer.lock().unwrap().capacity();
|
||||
let observed_discontinuity_generation = discontinuity_generation.load(Ordering::Relaxed);
|
||||
Ok(Self {
|
||||
audio_buffer,
|
||||
discontinuity_generation,
|
||||
observed_discontinuity_generation,
|
||||
buffered_input: vec![0.0; buffer_capacity],
|
||||
recovery,
|
||||
status: Default::default(),
|
||||
buffer_failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_buffer(&mut self, requested_samples: usize) -> usize {
|
||||
if self.buffer_failed {
|
||||
return 0;
|
||||
}
|
||||
let mut buffer = match self.audio_buffer.try_lock() {
|
||||
Ok(buffer) => buffer,
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
// Keep queued PCM and its generation for the next successful read.
|
||||
self.status.contentions.fetch_add(1, Ordering::Relaxed);
|
||||
return 0;
|
||||
}
|
||||
Err(TryLockError::Poisoned(_)) => {
|
||||
self.buffer_failed = true;
|
||||
self.status.ready.store(false, Ordering::Release);
|
||||
self.status.buffer_poisoned.store(true, Ordering::Relaxed);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
let generation = self.discontinuity_generation.load(Ordering::Relaxed);
|
||||
let channels = self.recovery.channels;
|
||||
let samples = buffer.occupied_len().min(requested_samples) / channels * channels;
|
||||
buffer.pop_slice(&mut self.buffered_input[..samples]);
|
||||
drop(buffer);
|
||||
if generation != self.observed_discontinuity_generation {
|
||||
self.recovery.begin_discontinuity();
|
||||
self.observed_discontinuity_generation = generation;
|
||||
}
|
||||
samples
|
||||
}
|
||||
|
||||
pub(super) fn write_output<T>(&mut self, output: &mut [T])
|
||||
where
|
||||
T: cpal::Sample + cpal::FromSample<f32>,
|
||||
{
|
||||
self.status
|
||||
.ready
|
||||
.store(!self.buffer_failed, Ordering::Release);
|
||||
let requested_samples = output.len().min(self.buffered_input.len());
|
||||
let channel_count = self.recovery.channels;
|
||||
let available_samples = self.read_buffer(requested_samples);
|
||||
let available_frames = available_samples / channel_count;
|
||||
for (frame_index, output_frame) in output.chunks_mut(channel_count).enumerate() {
|
||||
let input = if frame_index < available_frames {
|
||||
let start = frame_index * channel_count;
|
||||
Some(&self.buffered_input[start..start + channel_count])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match self.recovery.process_frame(input) {
|
||||
Ok(recovered) => {
|
||||
for (output, sample) in output_frame.iter_mut().zip(recovered) {
|
||||
*output = T::from_sample(*sample);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Failed to recover audio underflow: {error}");
|
||||
output_frame.fill(T::from_sample(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioPlaybackRecovery {
|
||||
pub(super) fn new(config: AudioPlaybackConfig) -> Result<Self, AudioPlaybackError> {
|
||||
if config.sample_rate == 0 || config.channels == 0 {
|
||||
return Err(AudioPlaybackError::InvalidConfig(config));
|
||||
}
|
||||
let transition_frames = (config.sample_rate as usize)
|
||||
.checked_mul(UNDERRUN_DECLICK_MS)
|
||||
.ok_or(AudioPlaybackError::FrameCountOverflow)?
|
||||
/ MILLISECONDS_PER_SECOND;
|
||||
if transition_frames == 0 {
|
||||
return Err(AudioPlaybackError::InvalidConfig(config));
|
||||
}
|
||||
Ok(Self {
|
||||
channels: config.channels,
|
||||
transition_frames,
|
||||
transition_frame: transition_frames,
|
||||
had_input: false,
|
||||
transition_start: vec![0.0; config.channels],
|
||||
output_frame: vec![0.0; config.channels],
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn process_frame(
|
||||
&mut self,
|
||||
input: Option<&[f32]>,
|
||||
) -> Result<&[f32], AudioPlaybackError> {
|
||||
if input.is_some_and(|frame| frame.len() != self.channels) {
|
||||
return Err(AudioPlaybackError::IncompleteFrame {
|
||||
samples: input.map_or(0, <[f32]>::len),
|
||||
channels: self.channels,
|
||||
});
|
||||
}
|
||||
self.begin_transition(input.is_some());
|
||||
let target_weight = self.advance_transition();
|
||||
for channel in 0..self.channels {
|
||||
let target = input.map_or(0.0, |frame| frame[channel]);
|
||||
self.output_frame[channel] =
|
||||
self.transition_start[channel] * (1.0 - target_weight) + target * target_weight;
|
||||
}
|
||||
Ok(&self.output_frame)
|
||||
}
|
||||
|
||||
pub(super) fn begin_discontinuity(&mut self) {
|
||||
self.transition_start.copy_from_slice(&self.output_frame);
|
||||
self.transition_frame = 0;
|
||||
}
|
||||
|
||||
fn begin_transition(&mut self, has_input: bool) {
|
||||
if has_input == self.had_input {
|
||||
return;
|
||||
}
|
||||
self.transition_start.copy_from_slice(&self.output_frame);
|
||||
self.transition_frame = 0;
|
||||
self.had_input = has_input;
|
||||
}
|
||||
|
||||
fn advance_transition(&mut self) -> f32 {
|
||||
if self.transition_frame >= self.transition_frames {
|
||||
return 1.0;
|
||||
}
|
||||
self.transition_frame += 1;
|
||||
self.transition_frame as f32 / self.transition_frames as f32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "audio_playback_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,184 +0,0 @@
|
||||
use super::{AudioDecoder, AudioFormat, AudioHandler, MediaData, Mono, Stereo};
|
||||
use cpal::StreamError;
|
||||
use crossbeam_queue::SegQueue;
|
||||
use hbb_common::{log, tokio::time::Instant, ResultType};
|
||||
use std::{
|
||||
sync::{atomic::Ordering, mpsc, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const RECOVERY_INTERVAL: Duration = Duration::from_secs(1);
|
||||
pub(super) const STARTUP_CONFIRMATION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
// The pinned WASAPI backend reports this warning but keeps its worker running.
|
||||
const PRIORITY_WARNING_PREFIX: &str = "SetThreadPriority failed: ";
|
||||
|
||||
#[path = "audio_playback_startup.rs"]
|
||||
mod startup;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PlaybackRecovery {
|
||||
pub(super) errors: Arc<SegQueue<StreamError>>,
|
||||
format: Option<AudioFormat>,
|
||||
pub(super) retry_at: Option<Instant>,
|
||||
restart_not_before: Option<Instant>,
|
||||
awaiting_callback: bool,
|
||||
startup_deadline: Option<Instant>,
|
||||
pending_output: Option<Box<AudioHandler>>,
|
||||
}
|
||||
|
||||
impl PlaybackRecovery {
|
||||
pub(super) fn new_error_callback(&mut self) -> impl FnMut(StreamError) + Send + 'static {
|
||||
self.errors = Default::default();
|
||||
let errors = self.errors.clone();
|
||||
move |error| errors.push(error)
|
||||
}
|
||||
|
||||
pub(super) fn report_pending(&self) -> bool {
|
||||
let mut failed = false;
|
||||
while let Some(error) = self.errors.pop() {
|
||||
if matches!(&error, StreamError::BackendSpecific { err }
|
||||
if err.description.starts_with(PRIORITY_WARNING_PREFIX))
|
||||
{
|
||||
log::warn!("Audio playback nonterminal priority warning: {error}");
|
||||
} else {
|
||||
log::error!("Audio playback stream failed: {error}");
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
failed
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioHandler {
|
||||
fn clear_playback_stream(&mut self) {
|
||||
// Dropping CPAL may join its worker; run this on the owner, not its callback.
|
||||
self.audio_stream = None;
|
||||
self.playback_recovery.report_pending();
|
||||
self.playback_status.report_errors();
|
||||
let recovery = std::mem::take(self).playback_recovery;
|
||||
self.playback_recovery.format = recovery.format;
|
||||
self.playback_recovery.retry_at = recovery.retry_at;
|
||||
self.playback_recovery.restart_not_before = recovery.restart_not_before;
|
||||
}
|
||||
|
||||
pub(super) fn prepare_playback(&mut self, format: &AudioFormat) {
|
||||
self.clear_playback_stream();
|
||||
self.playback_recovery.format = Some(format.clone());
|
||||
self.playback_recovery.retry_at = None;
|
||||
self.playback_recovery.restart_not_before = None;
|
||||
}
|
||||
|
||||
pub(super) fn finish_playback_start(&mut self, result: ResultType<()>) {
|
||||
let now = Instant::now();
|
||||
let retry_at = now + RECOVERY_INTERVAL;
|
||||
self.playback_recovery.restart_not_before = Some(retry_at);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.playback_recovery.retry_at = None;
|
||||
self.playback_recovery.awaiting_callback = true;
|
||||
self.playback_recovery.startup_deadline = Some(now + STARTUP_CONFIRMATION_TIMEOUT);
|
||||
log::info!("Audio playback stream opened; waiting for output callback");
|
||||
}
|
||||
Err(error) => {
|
||||
self.clear_playback_stream();
|
||||
self.playback_recovery.retry_at = Some(retry_at);
|
||||
log::error!(
|
||||
"Audio playback start failed: {error:#}; retrying in {RECOVERY_INTERVAL:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_start_timed_out(&mut self, now: Instant) -> bool {
|
||||
if !self.playback_recovery.awaiting_callback
|
||||
|| self.playback_status.ready.load(Ordering::Acquire)
|
||||
|| !self
|
||||
.playback_recovery
|
||||
.startup_deadline
|
||||
.is_some_and(|due| now >= due)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.playback_recovery.awaiting_callback = false;
|
||||
self.playback_recovery.startup_deadline = None;
|
||||
log::error!("Audio playback start timed out waiting for output callback");
|
||||
true
|
||||
}
|
||||
|
||||
fn restart_playback(&mut self, format: AudioFormat) -> ResultType<()> {
|
||||
let channels = if format.channels > 1 { Stereo } else { Mono };
|
||||
let decoder = AudioDecoder::new(format.sample_rate, channels)?;
|
||||
let buffer = vec![0.; format.sample_rate as usize * format.channels as usize];
|
||||
let channel_count = format.channels as _;
|
||||
self.start_audio(format)?;
|
||||
self.channels = channel_count;
|
||||
self.audio_decoder = Some((decoder, buffer));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn recover_playback_with(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
restart: impl FnOnce(&mut Self, AudioFormat) -> ResultType<()>,
|
||||
) {
|
||||
let failed = self.resolve_pending_playback(now).unwrap_or_else(|| {
|
||||
self.playback_recovery.report_pending() || self.playback_start_timed_out(now)
|
||||
});
|
||||
if failed {
|
||||
self.clear_playback_stream();
|
||||
self.playback_recovery.retry_at = Some(
|
||||
self.playback_recovery
|
||||
.restart_not_before
|
||||
.map_or(now, |due| due.max(now)),
|
||||
);
|
||||
}
|
||||
if self.playback_recovery.awaiting_callback
|
||||
&& self.playback_status.ready.load(Ordering::Acquire)
|
||||
{
|
||||
self.playback_recovery.awaiting_callback = false;
|
||||
self.playback_recovery.startup_deadline = None;
|
||||
log::info!("Audio playback output callback started");
|
||||
}
|
||||
if !self
|
||||
.playback_recovery
|
||||
.retry_at
|
||||
.is_some_and(|due| now >= due)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(format) = self.playback_recovery.format.clone() else {
|
||||
return;
|
||||
};
|
||||
log::info!("Recreating audio playback on the current default output device");
|
||||
let result = restart(self, format);
|
||||
self.finish_playback_start(result);
|
||||
}
|
||||
|
||||
pub(super) fn receive_audio(
|
||||
&mut self,
|
||||
receiver: &mpsc::Receiver<MediaData>,
|
||||
) -> Result<MediaData, mpsc::RecvError> {
|
||||
receive_with_recovery(receiver, RECOVERY_INTERVAL, || {
|
||||
self.recover_playback_with(Instant::now(), Self::restart_playback);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn receive_with_recovery(
|
||||
receiver: &mpsc::Receiver<MediaData>,
|
||||
interval: Duration,
|
||||
mut recover: impl FnMut(),
|
||||
) -> Result<MediaData, mpsc::RecvError> {
|
||||
loop {
|
||||
match receiver.recv_timeout(interval) {
|
||||
Ok(data) => {
|
||||
if !matches!(data, MediaData::AudioFormat(_)) {
|
||||
recover();
|
||||
}
|
||||
return Ok(data);
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => recover(),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => return Err(mpsc::RecvError),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
use super::{AudioHandler, Instant, Ordering, ResultType};
|
||||
use hbb_common::log;
|
||||
|
||||
impl AudioHandler {
|
||||
pub(in crate::client) fn cancel_pending_playback(&mut self) -> bool {
|
||||
// Format messages bypass recovery; retain a usable candidate before superseding it.
|
||||
let failed = self
|
||||
.resolve_pending_playback(Instant::now())
|
||||
.unwrap_or(false);
|
||||
if let Some(mut pending) = self.playback_recovery.pending_output.take() {
|
||||
pending.audio_stream = None;
|
||||
pending.playback_recovery.report_pending();
|
||||
pending.playback_status.report_errors();
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
pub(in crate::client) fn finish_playback_replacement(
|
||||
&mut self,
|
||||
result: ResultType<()>,
|
||||
previous: Option<Self>,
|
||||
) {
|
||||
self.finish_playback_start(result);
|
||||
let Some(mut previous) = previous else {
|
||||
return;
|
||||
};
|
||||
previous.audio_decoder = self.audio_decoder.take();
|
||||
let candidate = std::mem::replace(self, previous);
|
||||
self.playback_recovery.pending_output = Some(Box::new(candidate));
|
||||
log::info!("Audio playback replacement pending; continuing on the compatible output");
|
||||
self.recover_playback_with(Instant::now(), Self::restart_playback);
|
||||
}
|
||||
|
||||
pub(super) fn resolve_pending_playback(&mut self, now: Instant) -> Option<bool> {
|
||||
let mut candidate = self.playback_recovery.pending_output.take()?;
|
||||
let candidate_failed =
|
||||
candidate.playback_recovery.report_pending() || candidate.playback_start_timed_out(now);
|
||||
let previous_failed =
|
||||
self.playback_recovery.report_pending() || self.playback_start_timed_out(now);
|
||||
if candidate_failed {
|
||||
self.playback_recovery.restart_not_before =
|
||||
candidate.playback_recovery.restart_not_before;
|
||||
candidate.audio_stream = None;
|
||||
candidate.playback_recovery.report_pending();
|
||||
candidate.playback_status.report_errors();
|
||||
if !previous_failed {
|
||||
log::error!("Audio playback replacement failed before startup confirmation; keeping the existing compatible stream");
|
||||
}
|
||||
return Some(previous_failed);
|
||||
}
|
||||
if candidate.playback_status.ready.load(Ordering::Acquire) || previous_failed {
|
||||
candidate.audio_decoder = self.audio_decoder.take();
|
||||
self.audio_stream = None;
|
||||
self.playback_recovery.report_pending();
|
||||
self.playback_status.report_errors();
|
||||
*self = *candidate;
|
||||
return Some(false);
|
||||
}
|
||||
self.playback_recovery.pending_output = Some(candidate);
|
||||
// A second active-queue read could discard a healthy pending candidate.
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
use super::{
|
||||
AudioPlaybackConfig, AudioPlaybackError, AudioPlaybackRecovery, AudioPlaybackStatus,
|
||||
AudioPlaybackWriter,
|
||||
};
|
||||
use ringbuf::{ring_buffer::RbBase, Rb};
|
||||
use std::{
|
||||
sync::{atomic::Ordering, mpsc, Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
const CHANNELS: usize = 2;
|
||||
const ACTIVE_FRAME: [f32; CHANNELS] = [0.8, -0.8];
|
||||
const OPPOSITE_ACTIVE_FRAME: [f32; CHANNELS] = [-0.8, 0.8];
|
||||
const ACTIVE_FRAMES: usize = 300;
|
||||
const SILENT_FRAMES: usize = 300;
|
||||
const TRANSITION_FRAMES: usize =
|
||||
SAMPLE_RATE as usize * super::UNDERRUN_DECLICK_MS / super::MILLISECONDS_PER_SECOND;
|
||||
const MAX_SAMPLE_STEP: f32 = 0.01;
|
||||
|
||||
#[test]
|
||||
fn writing_audio_observes_discard_and_releases_buffer_lock() {
|
||||
const INPUT: [f32; 4] = [0.1, 0.2, 0.3, 0.4];
|
||||
const GENERATION: usize = 7;
|
||||
let buffer = Arc::new(Mutex::new(ringbuf::HeapRb::new(INPUT.len())));
|
||||
let generation = Arc::new(super::AtomicUsize::new(0));
|
||||
let config = AudioPlaybackConfig {
|
||||
sample_rate: SAMPLE_RATE,
|
||||
channels: CHANNELS,
|
||||
};
|
||||
let mut writer = AudioPlaybackWriter::new(config, buffer.clone(), generation.clone()).unwrap();
|
||||
{
|
||||
let mut buffer = buffer.lock().unwrap();
|
||||
buffer.push_slice(&INPUT);
|
||||
generation.store(GENERATION, Ordering::Relaxed);
|
||||
}
|
||||
let mut output = [0.0_f32; INPUT.len()];
|
||||
|
||||
writer.write_output(&mut output);
|
||||
|
||||
assert_eq!(writer.buffered_input, INPUT);
|
||||
assert_eq!(writer.observed_discontinuity_generation, GENERATION);
|
||||
assert_eq!(buffer.try_lock().unwrap().occupied_len(), 0);
|
||||
}
|
||||
|
||||
fn maximum_sample_step(samples: &[f32]) -> f32 {
|
||||
samples
|
||||
.windows(CHANNELS + 1)
|
||||
.map(|window| (window[CHANNELS] - window[0]).abs())
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooths_underflow_and_explicit_audio_discontinuities() {
|
||||
for explicit_discontinuity in [false, true] {
|
||||
let config = AudioPlaybackConfig {
|
||||
sample_rate: SAMPLE_RATE,
|
||||
channels: CHANNELS,
|
||||
};
|
||||
let mut recovery = AudioPlaybackRecovery::new(config).unwrap();
|
||||
let mut output = Vec::new();
|
||||
for _ in 0..ACTIVE_FRAMES {
|
||||
output.extend_from_slice(recovery.process_frame(Some(&ACTIVE_FRAME)).unwrap());
|
||||
}
|
||||
let transition_end = TRANSITION_FRAMES * CHANNELS;
|
||||
assert_eq!(
|
||||
&output[transition_end - CHANNELS..transition_end],
|
||||
ACTIVE_FRAME.as_slice(),
|
||||
"explicit_discontinuity={explicit_discontinuity}"
|
||||
);
|
||||
let resumed_frame = if explicit_discontinuity {
|
||||
recovery.begin_discontinuity();
|
||||
&OPPOSITE_ACTIVE_FRAME
|
||||
} else {
|
||||
for _ in 0..SILENT_FRAMES {
|
||||
output.extend_from_slice(recovery.process_frame(None).unwrap());
|
||||
}
|
||||
&ACTIVE_FRAME
|
||||
};
|
||||
for _ in 0..ACTIVE_FRAMES {
|
||||
output.extend_from_slice(recovery.process_frame(Some(resumed_frame)).unwrap());
|
||||
}
|
||||
let maximum = maximum_sample_step(&output);
|
||||
assert!(
|
||||
maximum <= MAX_SAMPLE_STEP,
|
||||
"step {maximum} exceeded {MAX_SAMPLE_STEP}, explicit={explicit_discontinuity}"
|
||||
);
|
||||
assert_eq!(
|
||||
&output[output.len() - CHANNELS..],
|
||||
resumed_frame,
|
||||
"explicit_discontinuity={explicit_discontinuity}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_configuration_and_frame_size() {
|
||||
let invalid_config = AudioPlaybackConfig {
|
||||
sample_rate: 0,
|
||||
channels: CHANNELS,
|
||||
};
|
||||
assert_eq!(
|
||||
AudioPlaybackRecovery::new(invalid_config).err(),
|
||||
Some(AudioPlaybackError::InvalidConfig(invalid_config))
|
||||
);
|
||||
|
||||
let config = AudioPlaybackConfig {
|
||||
sample_rate: SAMPLE_RATE,
|
||||
channels: CHANNELS,
|
||||
};
|
||||
let mut recovery = AudioPlaybackRecovery::new(config).unwrap();
|
||||
assert_eq!(
|
||||
recovery.process_frame(Some(&[0.5])).err(),
|
||||
Some(AudioPlaybackError::IncompleteFrame {
|
||||
samples: 1,
|
||||
channels: CHANNELS,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const CALLBACK_SAMPLES: usize = 64;
|
||||
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const DISCARD_GENERATION: usize = 1;
|
||||
|
||||
fn write_while_buffer_is_locked(
|
||||
mut writer: AudioPlaybackWriter,
|
||||
buffer: &Arc<Mutex<ringbuf::HeapRb<f32>>>,
|
||||
generation: &Arc<super::AtomicUsize>,
|
||||
) -> (AudioPlaybackWriter, [f32; CALLBACK_SAMPLES]) {
|
||||
let mut guard = buffer.lock().unwrap();
|
||||
let queued = OPPOSITE_ACTIVE_FRAME.repeat(ACTIVE_FRAMES);
|
||||
guard.push_slice(&queued);
|
||||
generation.store(DISCARD_GENERATION, Ordering::Relaxed);
|
||||
let (completed_tx, completed_rx) = mpsc::channel();
|
||||
let callback = std::thread::spawn(move || {
|
||||
let mut output = [0.0; CALLBACK_SAMPLES];
|
||||
crate::audio_resampler::allocation_tests::assert_no_allocations(|| {
|
||||
writer.write_output(&mut output);
|
||||
});
|
||||
completed_tx.send((writer, output)).unwrap();
|
||||
});
|
||||
let completed = completed_rx.recv_timeout(CALLBACK_TIMEOUT);
|
||||
let retained = guard.occupied_len();
|
||||
drop(guard);
|
||||
callback.join().unwrap();
|
||||
let result = completed.expect("playback callback waited for the buffer owner");
|
||||
assert_eq!(retained, queued.len());
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_contention_preserves_queued_audio_and_recovers_after_release() {
|
||||
let samples = ACTIVE_FRAMES * CHANNELS;
|
||||
let buffer = Arc::new(Mutex::new(ringbuf::HeapRb::new(samples)));
|
||||
let generation = Arc::new(super::AtomicUsize::new(0));
|
||||
let config = AudioPlaybackConfig {
|
||||
sample_rate: SAMPLE_RATE,
|
||||
channels: CHANNELS,
|
||||
};
|
||||
let mut writer = AudioPlaybackWriter::new(config, buffer.clone(), generation.clone()).unwrap();
|
||||
buffer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_slice(&ACTIVE_FRAME.repeat(ACTIVE_FRAMES));
|
||||
let mut output = vec![0.0; samples];
|
||||
writer.write_output(&mut output);
|
||||
assert_eq!(&output[samples - CHANNELS..], &ACTIVE_FRAME);
|
||||
|
||||
let (mut writer, gap) = write_while_buffer_is_locked(writer, &buffer, &generation);
|
||||
|
||||
assert_eq!(writer.status.contentions.load(Ordering::Relaxed), 1);
|
||||
assert!(writer.status.ready.load(Ordering::Acquire));
|
||||
assert_eq!(writer.observed_discontinuity_generation, 0);
|
||||
assert!(maximum_sample_step(&gap) <= MAX_SAMPLE_STEP);
|
||||
assert!(gap[0] > 0.0 && gap[0] < ACTIVE_FRAME[0]);
|
||||
assert_eq!(gap[1], -gap[0]);
|
||||
assert!(gap[CALLBACK_SAMPLES - CHANNELS] > 0.0);
|
||||
writer.write_output(&mut output);
|
||||
let mut transition = gap[gap.len() - CHANNELS..].to_vec();
|
||||
transition.extend_from_slice(&output[..TRANSITION_FRAMES * CHANNELS]);
|
||||
assert!(maximum_sample_step(&transition) <= MAX_SAMPLE_STEP);
|
||||
assert_eq!(
|
||||
writer.buffered_input,
|
||||
OPPOSITE_ACTIVE_FRAME.repeat(ACTIVE_FRAMES)
|
||||
);
|
||||
assert_eq!(writer.observed_discontinuity_generation, DISCARD_GENERATION);
|
||||
assert_eq!(&output[samples - CHANNELS..], &OPPOSITE_ACTIVE_FRAME);
|
||||
assert_eq!(buffer.lock().unwrap().occupied_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poisoned_playback_buffer_reports_once_without_panicking_in_the_callback() {
|
||||
let buffer = Arc::new(Mutex::new(ringbuf::HeapRb::new(CALLBACK_SAMPLES)));
|
||||
let config = AudioPlaybackConfig {
|
||||
sample_rate: SAMPLE_RATE,
|
||||
channels: CHANNELS,
|
||||
};
|
||||
let mut writer =
|
||||
AudioPlaybackWriter::new(config, buffer.clone(), Arc::new(super::AtomicUsize::new(0)))
|
||||
.unwrap();
|
||||
assert!(std::thread::spawn(move || {
|
||||
let _guard = buffer.lock().unwrap();
|
||||
panic!("Injected PCM buffer failure");
|
||||
})
|
||||
.join()
|
||||
.is_err());
|
||||
let mut output = [ACTIVE_FRAME[0]; CALLBACK_SAMPLES];
|
||||
|
||||
crate::audio_resampler::allocation_tests::assert_no_allocations(|| {
|
||||
writer.write_output(&mut output);
|
||||
});
|
||||
|
||||
assert_eq!(output, [0.0; CALLBACK_SAMPLES]);
|
||||
assert!(!writer.status.ready.load(Ordering::Acquire));
|
||||
assert!(writer.status.buffer_poisoned.load(Ordering::Relaxed));
|
||||
writer.status.report_errors();
|
||||
writer.write_output(&mut output);
|
||||
assert!(!writer.status.buffer_poisoned.load(Ordering::Relaxed));
|
||||
assert_eq!(writer.status.contentions.load(Ordering::Relaxed), 0);
|
||||
assert!(!writer.status.ready.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contention_counts_accumulate_until_the_next_report() {
|
||||
let status = AudioPlaybackStatus::default();
|
||||
status.report_errors();
|
||||
status.contentions.fetch_add(1, Ordering::Relaxed);
|
||||
status.report_errors();
|
||||
assert_eq!(status.contentions.load(Ordering::Relaxed), 0);
|
||||
|
||||
let mut total = 0;
|
||||
for callbacks in [3, 7, 2] {
|
||||
total += callbacks;
|
||||
status.contentions.fetch_add(callbacks, Ordering::Relaxed);
|
||||
status.report_errors();
|
||||
assert_eq!(status.contentions.load(Ordering::Relaxed), total);
|
||||
}
|
||||
status.buffer_poisoned.store(true, Ordering::Relaxed);
|
||||
status.report_errors();
|
||||
assert!(!status.buffer_poisoned.load(Ordering::Relaxed));
|
||||
assert_eq!(status.contentions.load(Ordering::Relaxed), total);
|
||||
|
||||
std::thread::sleep(super::AUDIO_PLAYBACK_LOG_INTERVAL);
|
||||
status.report_errors();
|
||||
assert_eq!(status.contentions.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contention_reporting_is_independent_between_playbacks() {
|
||||
let first = AudioPlaybackStatus::default();
|
||||
let second = AudioPlaybackStatus::default();
|
||||
for status in [&first, &second] {
|
||||
status.contentions.fetch_add(1, Ordering::Relaxed);
|
||||
status.report_errors();
|
||||
assert_eq!(status.contentions.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
for status in [&first, &second] {
|
||||
status.contentions.fetch_add(1, Ordering::Relaxed);
|
||||
status.report_errors();
|
||||
assert_eq!(status.contentions.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
}
|
||||
@@ -598,9 +598,6 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
} else {
|
||||
log::debug!("Failed to record local audio channel: {}", err);
|
||||
}
|
||||
// Both arms fall through with nothing else in this loop blocking, so
|
||||
// without a pause the thread spun a core for the whole voice call.
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1511,7 +1508,11 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
_ => {}
|
||||
},
|
||||
Some(message::Union::CursorData(cd)) => {
|
||||
self.handler.set_cursor_data(cd);
|
||||
let id = cd.id;
|
||||
match decode_cursor_data(cd) {
|
||||
Ok(cd) => self.handler.set_cursor_data(cd),
|
||||
Err(err) => log::warn!("Rejected cursor {id}: {err}"),
|
||||
}
|
||||
}
|
||||
Some(message::Union::CursorId(id)) => {
|
||||
self.handler.set_cursor_id(id.to_string());
|
||||
@@ -2545,6 +2546,53 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// Both UI handlers receive validated, uncompressed RGBA from the receive loop.
|
||||
fn decode_cursor_data(data: CursorData) -> hbb_common::ResultType<CursorData> {
|
||||
use hbb_common::{anyhow::anyhow, bail};
|
||||
|
||||
const MAX_CURSOR_SIZE: i32 = 4096;
|
||||
const RGBA_CHANNELS: usize = 4;
|
||||
|
||||
let mut cd = data;
|
||||
// Sciter keeps the legacy outer image; Flutter uses the physical variant.
|
||||
#[cfg(feature = "flutter")]
|
||||
if let Some(physical) = cd.high_resolution.take() {
|
||||
cd = physical;
|
||||
}
|
||||
if !(1..=MAX_CURSOR_SIZE).contains(&cd.width) || !(1..=MAX_CURSOR_SIZE).contains(&cd.height) {
|
||||
bail!("invalid source size {}x{}", cd.width, cd.height);
|
||||
}
|
||||
if !(0..cd.width).contains(&cd.hotx) || !(0..cd.height).contains(&cd.hoty) {
|
||||
bail!(
|
||||
"hotspot ({},{}) is outside the cursor image",
|
||||
cd.hotx,
|
||||
cd.hoty
|
||||
);
|
||||
}
|
||||
// Zero preserves legacy sizing; positive density must bound the logical image too.
|
||||
if !cd.scale.is_finite()
|
||||
|| cd.scale < 0.0
|
||||
|| (cd.scale > 0.0
|
||||
&& (f64::from(cd.width) / cd.scale > f64::from(MAX_CURSOR_SIZE)
|
||||
|| f64::from(cd.height) / cd.scale > f64::from(MAX_CURSOR_SIZE)))
|
||||
{
|
||||
bail!("invalid cursor density {}", cd.scale);
|
||||
}
|
||||
let expected = (cd.width as usize)
|
||||
.checked_mul(cd.height as usize)
|
||||
.and_then(|pixels| pixels.checked_mul(RGBA_CHANNELS))
|
||||
.ok_or_else(|| anyhow!("cursor RGBA size overflow"))?;
|
||||
let colors = zstd::bulk::decompress(&cd.colors, expected)?;
|
||||
if colors.len() != expected {
|
||||
bail!(
|
||||
"invalid RGBA length: expected {expected}, got {}",
|
||||
colors.len()
|
||||
);
|
||||
}
|
||||
cd.colors = colors.into();
|
||||
Ok(cd)
|
||||
}
|
||||
|
||||
struct RemoveJob {
|
||||
files: Vec<FileEntry>,
|
||||
path: String,
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
use super::*;
|
||||
use crate::client::{
|
||||
audio_playback::AudioPlaybackStatus, audio_playback_recovery::STARTUP_CONFIRMATION_TIMEOUT,
|
||||
};
|
||||
use cpal::StreamError;
|
||||
use crossbeam_queue::SegQueue;
|
||||
use hbb_common::tokio::time::Instant;
|
||||
use std::time::Duration;
|
||||
|
||||
const AFTER_COOLDOWN: Duration = Duration::from_secs(2);
|
||||
|
||||
type PendingOutput = (
|
||||
Arc<AtomicBool>,
|
||||
Arc<AudioPlaybackStatus>,
|
||||
Arc<SegQueue<StreamError>>,
|
||||
);
|
||||
|
||||
fn install_output(handler: &mut AudioHandler, dropped: Arc<AtomicBool>) {
|
||||
handler.sample_rate = (INPUT_RATE, OUTPUT_RATE);
|
||||
handler.device_channel = CHANNELS;
|
||||
handler.audio_stream = Some(Box::new(TrackedAudioStream(dropped)));
|
||||
handler.playback_status.ready.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
fn recovery_handler() -> (AudioHandler, Arc<AtomicBool>) {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let mut handler = AudioHandler::default();
|
||||
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), |candidate, _| {
|
||||
install_output(candidate, dropped.clone());
|
||||
Ok(())
|
||||
});
|
||||
(handler, dropped)
|
||||
}
|
||||
|
||||
fn begin_pending(handler: &mut AudioHandler) -> PendingOutput {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let mut state = None;
|
||||
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), |candidate, _| {
|
||||
install_output(candidate, dropped.clone());
|
||||
candidate
|
||||
.playback_status
|
||||
.ready
|
||||
.store(false, Ordering::Release);
|
||||
state = Some((
|
||||
candidate.playback_status.clone(),
|
||||
candidate.playback_recovery.errors.clone(),
|
||||
));
|
||||
Ok(())
|
||||
});
|
||||
let (status, errors) = state.unwrap();
|
||||
(dropped, status, errors)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconfirmed_start_retries_without_callback_or_error() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let mut handler = AudioHandler::default();
|
||||
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), |candidate, _| {
|
||||
install_output(candidate, dropped.clone());
|
||||
candidate
|
||||
.playback_status
|
||||
.ready
|
||||
.store(false, Ordering::Release);
|
||||
Ok(())
|
||||
});
|
||||
handler.recover_playback_with(Instant::now(), |_, _| {
|
||||
panic!("Startup confirmation deadline has not elapsed")
|
||||
});
|
||||
assert!(!dropped.load(Ordering::SeqCst));
|
||||
let expired = Instant::now() + STARTUP_CONFIRMATION_TIMEOUT;
|
||||
let mut attempts = 0;
|
||||
handler.recover_playback_with(expired, |candidate, requested| {
|
||||
attempts += 1;
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
assert_eq!(requested, format(INPUT_RATE, CHANNELS));
|
||||
install_output(candidate, Arc::new(AtomicBool::new(false)));
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(attempts, 1);
|
||||
handler.recover_playback_with(expired + STARTUP_CONFIRMATION_TIMEOUT, |_, _| {
|
||||
panic!("Confirmed output must not be reopened")
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_terminal_candidate_cannot_replace_compatible_output() {
|
||||
let (mut handler, dropped) = recovery_handler();
|
||||
let candidate_dropped = Arc::new(AtomicBool::new(false));
|
||||
let old_buffer = handler.audio_buffer.0.clone();
|
||||
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), |candidate, _| {
|
||||
install_output(candidate, candidate_dropped.clone());
|
||||
candidate
|
||||
.playback_recovery
|
||||
.errors
|
||||
.push(StreamError::DeviceNotAvailable);
|
||||
Ok(())
|
||||
});
|
||||
assert!(!dropped.load(Ordering::SeqCst));
|
||||
assert!(candidate_dropped.load(Ordering::SeqCst));
|
||||
assert!(Arc::ptr_eq(&old_buffer, &handler.audio_buffer.0));
|
||||
handler.recover_playback_with(Instant::now() + AFTER_COOLDOWN, |_, _| {
|
||||
panic!("Compatible active output must not be reopened")
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_output_keeps_playing_and_transfers_decoder_history_on_commit() {
|
||||
let (mut handler, old_dropped) = recovery_handler();
|
||||
let old_buffer = handler.audio_buffer.0.clone();
|
||||
let (_, status, _) = begin_pending(&mut handler);
|
||||
let frame = audio_frame();
|
||||
let (mut reference, mut expected) = decoder(INPUT_RATE);
|
||||
reference
|
||||
.decode_float(&frame.data, &mut expected, false)
|
||||
.unwrap();
|
||||
handler.handle_frame(frame.clone());
|
||||
assert!(Arc::ptr_eq(&old_buffer, &handler.audio_buffer.0));
|
||||
assert!(!drain_audio(&handler).is_empty());
|
||||
status.ready.store(true, Ordering::Release);
|
||||
handler.recover_playback_with(Instant::now(), |_, _| panic!("Candidate already exists"));
|
||||
assert!(old_dropped.load(Ordering::SeqCst));
|
||||
assert!(Arc::ptr_eq(&status, &handler.playback_status));
|
||||
let samples = reference
|
||||
.decode_float(&frame.data, &mut expected, false)
|
||||
.unwrap()
|
||||
* CHANNELS as usize;
|
||||
handler.handle_frame(frame);
|
||||
assert_eq!(
|
||||
&handler.audio_decoder.as_ref().unwrap().1[..samples],
|
||||
&expected[..samples]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_keeps_the_restarted_decoders_accumulated_history() {
|
||||
let (mut handler, old_dropped) = recovery_handler();
|
||||
let frame = audio_frame();
|
||||
handler.handle_frame(frame.clone());
|
||||
let (candidate_dropped, _, errors) = begin_pending(&mut handler);
|
||||
let (mut reference, mut expected) = decoder(INPUT_RATE);
|
||||
handler.handle_frame(frame.clone());
|
||||
reference
|
||||
.decode_float(&frame.data, &mut expected, false)
|
||||
.unwrap();
|
||||
errors.push(StreamError::DeviceNotAvailable);
|
||||
handler.recover_playback_with(Instant::now(), |_, _| panic!("Old output still works"));
|
||||
let samples = reference
|
||||
.decode_float(&frame.data, &mut expected, false)
|
||||
.unwrap()
|
||||
* CHANNELS as usize;
|
||||
handler.handle_frame(frame);
|
||||
assert!(!old_dropped.load(Ordering::SeqCst));
|
||||
assert!(candidate_dropped.load(Ordering::SeqCst));
|
||||
assert_eq!(
|
||||
&handler.audio_decoder.as_ref().unwrap().1[..samples],
|
||||
&expected[..samples]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_compatible_format_retires_only_the_pending_attempt() {
|
||||
let (mut handler, old_dropped) = recovery_handler();
|
||||
let (first_dropped, _, first_errors) = begin_pending(&mut handler);
|
||||
let (second_dropped, second_status, _) = begin_pending(&mut handler);
|
||||
assert!(first_dropped.load(Ordering::SeqCst));
|
||||
assert!(!old_dropped.load(Ordering::SeqCst));
|
||||
first_errors.push(StreamError::DeviceNotAvailable);
|
||||
second_status.ready.store(true, Ordering::Release);
|
||||
handler.recover_playback_with(Instant::now(), |_, _| {
|
||||
panic!("Retired attempt affected current output")
|
||||
});
|
||||
assert!(old_dropped.load(Ordering::SeqCst));
|
||||
assert!(!second_dropped.load(Ordering::SeqCst));
|
||||
assert!(Arc::ptr_eq(&second_status, &handler.playback_status));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_outputs_failing_retains_format_and_paces_recovery() {
|
||||
let (mut handler, old_dropped) = recovery_handler();
|
||||
let old_errors = handler.playback_recovery.errors.clone();
|
||||
let (candidate_dropped, _, errors) = begin_pending(&mut handler);
|
||||
old_errors.push(StreamError::DeviceNotAvailable);
|
||||
errors.push(StreamError::DeviceNotAvailable);
|
||||
handler.recover_playback_with(Instant::now(), |_, _| panic!("Retry must be paced"));
|
||||
assert!(old_dropped.load(Ordering::SeqCst));
|
||||
assert!(candidate_dropped.load(Ordering::SeqCst));
|
||||
assert!(handler.audio_stream.is_none());
|
||||
let due = handler.playback_recovery.retry_at.unwrap();
|
||||
let mut attempts = 0;
|
||||
handler.recover_playback_with(due, |_, requested| {
|
||||
attempts += 1;
|
||||
assert_eq!(requested, format(INPUT_RATE, CHANNELS));
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(attempts, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseding_format_keeps_ready_candidate_when_active_output_failed() {
|
||||
let (mut handler, old_dropped) = recovery_handler();
|
||||
let old_errors = handler.playback_recovery.errors.clone();
|
||||
let (candidate_dropped, candidate_status, _) = begin_pending(&mut handler);
|
||||
candidate_status.ready.store(true, Ordering::Release);
|
||||
old_errors.push(StreamError::DeviceNotAvailable);
|
||||
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), failed_output);
|
||||
assert!(!candidate_dropped.load(Ordering::SeqCst));
|
||||
assert!(old_dropped.load(Ordering::SeqCst));
|
||||
assert!(Arc::ptr_eq(&candidate_status, &handler.playback_status));
|
||||
assert!(handler.audio_decoder.is_some());
|
||||
assert!(handler.playback_recovery.retry_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseding_format_preserves_failure_when_both_outputs_failed() {
|
||||
let (mut handler, old_dropped) = recovery_handler();
|
||||
let old_errors = handler.playback_recovery.errors.clone();
|
||||
let (candidate_dropped, status, errors) = begin_pending(&mut handler);
|
||||
status.ready.store(true, Ordering::Release);
|
||||
old_errors.push(StreamError::DeviceNotAvailable);
|
||||
errors.push(StreamError::DeviceNotAvailable);
|
||||
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), failed_output);
|
||||
assert!(candidate_dropped.load(Ordering::SeqCst));
|
||||
assert!(old_dropped.load(Ordering::SeqCst));
|
||||
assert!(handler.audio_stream.is_none());
|
||||
assert!(handler.playback_recovery.retry_at.is_some());
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
use super::{create_audio_resampler, AudioDecoder, AudioFormat, AudioFrame, AudioHandler, Stereo};
|
||||
use cpal::traits::StreamTrait;
|
||||
use hbb_common::{anyhow::anyhow, ResultType};
|
||||
use magnum_opus::{Application::LowDelay, Encoder};
|
||||
use ringbuf::Rb;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
const INPUT_RATE: u32 = 24_000;
|
||||
const OUTPUT_RATE: u32 = 48_000;
|
||||
const CHANNELS: u16 = 2;
|
||||
const PACKETS_PER_SECOND: usize = 100;
|
||||
const MAX_PACKET_BYTES: usize = 4_096;
|
||||
const SAMPLE_VALUE: f32 = 0.25;
|
||||
const MONO_CHANNELS: u16 = 1;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio_playback_recovery_tests.rs"]
|
||||
mod recovery_tests;
|
||||
|
||||
struct TrackedAudioStream(Arc<AtomicBool>);
|
||||
|
||||
impl StreamTrait for TrackedAudioStream {
|
||||
fn play(&self) -> Result<(), cpal::PlayStreamError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), cpal::PauseStreamError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TrackedAudioStream {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn decoder(sample_rate: u32) -> (AudioDecoder, Vec<f32>) {
|
||||
(
|
||||
AudioDecoder::new(sample_rate, Stereo).unwrap(),
|
||||
vec![0.0; sample_rate as usize * CHANNELS as usize],
|
||||
)
|
||||
}
|
||||
|
||||
fn active_handler(input_rate: u32) -> (AudioHandler, Arc<AtomicBool>) {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let handler = AudioHandler {
|
||||
audio_decoder: Some(decoder(input_rate)),
|
||||
audio_resampler: create_audio_resampler(input_rate, OUTPUT_RATE, CHANNELS).unwrap(),
|
||||
sample_rate: (input_rate, OUTPUT_RATE),
|
||||
audio_stream: Some(Box::new(TrackedAudioStream(dropped.clone()))),
|
||||
channels: CHANNELS,
|
||||
device_channel: CHANNELS,
|
||||
..Default::default()
|
||||
};
|
||||
handler.playback_status.ready.store(true, Ordering::Release);
|
||||
(handler, dropped)
|
||||
}
|
||||
|
||||
fn audio_frame() -> AudioFrame {
|
||||
let samples = OUTPUT_RATE as usize / PACKETS_PER_SECOND * CHANNELS as usize;
|
||||
let mut encoder = Encoder::new(OUTPUT_RATE, Stereo, LowDelay).unwrap();
|
||||
AudioFrame {
|
||||
data: encoder
|
||||
.encode_vec_float(&vec![SAMPLE_VALUE; samples], MAX_PACKET_BYTES)
|
||||
.unwrap()
|
||||
.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_output(candidate: &mut AudioHandler, _: AudioFormat) -> ResultType<()> {
|
||||
candidate.sample_rate = (INPUT_RATE, INPUT_RATE);
|
||||
candidate.device_channel = MONO_CHANNELS;
|
||||
candidate
|
||||
.audio_buffer
|
||||
.resize(INPUT_RATE as _, MONO_CHANNELS as _);
|
||||
candidate
|
||||
.playback_status
|
||||
.ready
|
||||
.store(false, Ordering::Release);
|
||||
Err(anyhow!("Injected playback failure"))
|
||||
}
|
||||
|
||||
fn format(sample_rate: u32, channels: u16) -> AudioFormat {
|
||||
AudioFormat {
|
||||
sample_rate,
|
||||
channels: u32::from(channels),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_format_failure_preserves_playback_and_resampler_history() {
|
||||
let (mut handler, dropped) = active_handler(INPUT_RATE);
|
||||
let (mut reference, _) = active_handler(INPUT_RATE);
|
||||
let (mut retained_decoder, _) = active_handler(INPUT_RATE);
|
||||
retained_decoder.handle_frame(audio_frame());
|
||||
retained_decoder.handle_frame(audio_frame());
|
||||
handler.handle_frame(audio_frame());
|
||||
reference.handle_frame(audio_frame());
|
||||
let buffer = handler.audio_buffer.0.clone();
|
||||
let status = handler.playback_status.clone();
|
||||
|
||||
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), failed_output);
|
||||
reference.audio_decoder = Some(decoder(INPUT_RATE));
|
||||
handler.handle_frame(audio_frame());
|
||||
reference.handle_frame(audio_frame());
|
||||
|
||||
assert!(!dropped.load(Ordering::SeqCst));
|
||||
assert!(Arc::ptr_eq(&buffer, &handler.audio_buffer.0));
|
||||
assert!(Arc::ptr_eq(&status, &handler.playback_status));
|
||||
assert_eq!(handler.sample_rate, (INPUT_RATE, OUTPUT_RATE));
|
||||
assert_eq!(handler.device_channel, CHANNELS);
|
||||
assert!(handler.playback_status.ready.load(Ordering::Acquire));
|
||||
let expected = drain_audio(&reference);
|
||||
let actual = drain_audio(&handler);
|
||||
assert!(!actual.is_empty());
|
||||
assert_ne!(drain_audio(&retained_decoder), expected);
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
fn drain_audio(handler: &AudioHandler) -> Vec<f32> {
|
||||
handler.audio_buffer.0.lock().unwrap().pop_iter().collect()
|
||||
}
|
||||
@@ -408,11 +408,6 @@ pub fn resample_channels(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "use_dasp", feature = "use_samplerate"))]
|
||||
compile_error!(
|
||||
"features `use_dasp` and `use_samplerate` are mutually exclusive; disable default features before selecting `use_samplerate`"
|
||||
);
|
||||
|
||||
#[cfg(feature = "use_dasp")]
|
||||
pub fn audio_resample(
|
||||
data: &[f32],
|
||||
@@ -449,7 +444,7 @@ pub fn audio_resample(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
|
||||
#[cfg(feature = "use_samplerate")]
|
||||
pub fn audio_resample(
|
||||
data: &[f32],
|
||||
sample_rate0: u32,
|
||||
@@ -2166,13 +2161,6 @@ pub fn decode_id_pk_dtls(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the DTLS fingerprint a WebRTC peer signed into its identity is the one of the channel
|
||||
/// actually negotiated. An empty signed value binds nothing: on a WebRTC channel it is either a
|
||||
/// peer that could not sign one or a rendezvous/relay that stripped it, and both fail closed.
|
||||
pub fn dtls_fingerprint_bound(signed_fp: &str, actual_fp: &str) -> bool {
|
||||
!signed_fp.is_empty() && signed_fp == actual_fp
|
||||
}
|
||||
|
||||
pub fn create_symmetric_key_msg(their_pk_b: [u8; 32]) -> (Bytes, Bytes, secretbox::Key) {
|
||||
let their_pk_b = box_::PublicKey(their_pk_b);
|
||||
let (our_pk_b, out_sk_b) = box_::gen_keypair();
|
||||
@@ -3270,42 +3258,4 @@ mod tests {
|
||||
assert_eq!(combined_mask & MOUSE_TYPE_MASK, MOUSE_TYPE_DOWN);
|
||||
assert_eq!(combined_mask >> 3, MOUSE_BUTTON_LEFT | MOUSE_BUTTON_RIGHT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dtls_fingerprint_travels_signed_and_binds() {
|
||||
let (pk, sk) = sign::gen_keypair();
|
||||
let fp = "sha-256 0A:1B:2C";
|
||||
let signed = sign::sign(
|
||||
&IdPk {
|
||||
id: "123456789".to_owned(),
|
||||
pk: Bytes::from(vec![7u8; 32]),
|
||||
dtls_fingerprint: fp.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
.write_to_bytes()
|
||||
.unwrap(),
|
||||
&sk,
|
||||
);
|
||||
|
||||
let (id, their_pk, signed_fp) = decode_id_pk_dtls(&signed, &pk).unwrap();
|
||||
assert_eq!(id, "123456789");
|
||||
assert_eq!(their_pk, [7u8; 32]);
|
||||
assert_eq!(signed_fp, fp);
|
||||
assert!(dtls_fingerprint_bound(&signed_fp, fp));
|
||||
assert!(!dtls_fingerprint_bound(&signed_fp, "sha-256 0A:1B:2D"));
|
||||
assert!(!dtls_fingerprint_bound("", ""));
|
||||
|
||||
// The fingerprint is under the signature: a blob verified with another key yields
|
||||
// nothing, and one whose payload was edited in transit fails verification.
|
||||
let (other_pk, _) = sign::gen_keypair();
|
||||
assert!(decode_id_pk_dtls(&signed, &other_pk).is_err());
|
||||
let mut tampered = signed.clone();
|
||||
let last = tampered.len() - 1;
|
||||
tampered[last] ^= 1;
|
||||
assert!(decode_id_pk_dtls(&tampered, &pk).is_err());
|
||||
|
||||
// `decode_id_pk` is the same blob minus the fingerprint, so the field is invisible to
|
||||
// non-WebRTC handshakes.
|
||||
assert_eq!(decode_id_pk(&signed, &pk).unwrap(), (id, their_pk));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use hbb_common::{
|
||||
bail,
|
||||
base64::{
|
||||
engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD},
|
||||
Engine as _,
|
||||
},
|
||||
base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _},
|
||||
sodiumoxide::crypto::sign,
|
||||
ResultType,
|
||||
};
|
||||
@@ -28,9 +25,7 @@ fn get_custom_server_from_config_string(s: &str) -> ResultType<CustomServer> {
|
||||
12, 46, 129, 83, 17, 84, 193, 119, 197, 130, 103,
|
||||
];
|
||||
let pk = sign::PublicKey(*PK);
|
||||
let data = URL_SAFE_NO_PAD
|
||||
.decode(&tmp)
|
||||
.or_else(|_| URL_SAFE.decode(&tmp))?;
|
||||
let data = URL_SAFE_NO_PAD.decode(tmp)?;
|
||||
if let Ok(lic) = serde_json::from_slice::<CustomServer>(&data) {
|
||||
return Ok(lic);
|
||||
}
|
||||
@@ -220,16 +215,5 @@ mod test {
|
||||
assert_eq!(
|
||||
get_custom_server_from_string("rustdesk-licensed--0nI900VsFHZVBVdIlncwpHS4V0bOZ0dtVldrpVO4JHdCp0YV5WdzUGZzdnYRVjI6ISeltmIsISMuEjLx4SMiojI0N3boJye--.exe")
|
||||
.unwrap(), lic);
|
||||
// padded base64 (one '=' after reversal) is accepted, wrong padding is not
|
||||
assert_eq!(
|
||||
get_custom_server_from_string("rustdesk-licensed-=0nI900VsFHZVBVdIlncwpHS4V0bOZ0dtVldrpVO4JHdCp0YV5WdzUGZzdnYRVjI6ISeltmIsISMuEjLx4SMiojI0N3boJye.exe")
|
||||
.unwrap(), lic);
|
||||
assert!(
|
||||
get_custom_server_from_string("rustdesk-licensed-==0nI900VsFHZVBVdIlncwpHS4V0bOZ0dtVldrpVO4JHdCp0YV5WdzUGZzdnYRVjI6ISeltmIsISMuEjLx4SMiojI0N3boJye.exe")
|
||||
.is_err());
|
||||
// bare string as passed to `--config`
|
||||
assert_eq!(
|
||||
get_custom_server_from_string("=0nI900VsFHZVBVdIlncwpHS4V0bOZ0dtVldrpVO4JHdCp0YV5WdzUGZzdnYRVjI6ISeltmIsISMuEjLx4SMiojI0N3boJye.exe")
|
||||
.unwrap(), lic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,7 +642,7 @@ impl FlutterHandler {
|
||||
|
||||
impl InvokeUiSession for FlutterHandler {
|
||||
fn set_cursor_data(&self, cd: CursorData) {
|
||||
let colors = hbb_common::compress::decompress(&cd.colors);
|
||||
let colors = &cd.colors;
|
||||
self.push_event(
|
||||
"cursor_data",
|
||||
&[
|
||||
@@ -651,6 +651,7 @@ impl InvokeUiSession for FlutterHandler {
|
||||
("hoty", &cd.hoty.to_string()),
|
||||
("width", &cd.width.to_string()),
|
||||
("height", &cd.height.to_string()),
|
||||
("scale", &cd.scale.to_string()),
|
||||
(
|
||||
"colors",
|
||||
&serde_json::ser::to_string(&colors).unwrap_or("".to_owned()),
|
||||
|
||||
@@ -1497,13 +1497,7 @@ pub async fn start_pa() {
|
||||
None, // Use default buffering attributes
|
||||
) {
|
||||
Ok(s) => loop {
|
||||
// A dead pulse handle fails every read at once, so ignoring the
|
||||
// error left nothing pacing this loop and it burned a core.
|
||||
if let Err(err) = s.read(&mut buf) {
|
||||
log::error!("Failed to read audio data:{}", err);
|
||||
break;
|
||||
}
|
||||
{
|
||||
if let Ok(_) = s.read(&mut buf) {
|
||||
let out =
|
||||
if buf.iter().filter(|x| **x != 0).next().is_none() {
|
||||
vec![]
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "انتهى طلب مشاركة الشاشة على الجهاز البعيد دون أن يكتمل"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "تعذّر على RustDesk الحصول على شاشة قابلة للاستخدام من XDG Desktop Portal، قد تكون مكتبة PipeWire قديمة جدًا"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "تعذّر على RustDesk تحميل مكوّن GStreamer اللازم لالتقاط الشاشة ({})"),
|
||||
("Relay fallback delay in seconds", "مهلة التراجع إلى الترحيل بالثواني"),
|
||||
("relay-fallback-delay-tip", "المدة التي ينتظرها اتصال الترحيل القائم بالفعل الاتصالَ المباشر عبر WebRTC قبل أن يُستخدم بدلًا منه. زِدها لمنح الاتصال المباشر البطيء فرصة أكبر للفوز؛ وقلّلها للاستقرار على الترحيل أسرع في الشبكات التي يتعذر فيها الاتصال المباشر. اتركها فارغة للقيمة الافتراضية 2.5 ثانية."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -770,16 +770,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("port-forward-mux-tip", "Port yönləndirmə xəritələnməsinin hər əlaqəsini qarşı tərəfə açılan tək əlaqə üzərindən daşıyır, hər biri üçün yenidən qoşulub giriş etmək əvəzinə."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P əlaqəsini aktivləşdir"),
|
||||
("Enable TCP hole punching", "TCP deşik açmanı aktivləşdir"),
|
||||
("The screen sharing request was declined on the remote device", ""),
|
||||
("The screen sharing request timed out on the remote device", ""),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", ""),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", ""),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", ""),
|
||||
("The screen sharing request ended without completing on the remote device", ""),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", ""),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", ""),
|
||||
("Relay fallback delay in seconds", "Ötürücüyə keçid gecikməsi, saniyə"),
|
||||
("relay-fallback-delay-tip", "Artıq qurulmuş ötürücü bağlantı birbaşa WebRTC bağlantısını nə qədər gözləyir, sonra onun əvəzinə istifadə olunur. Yavaş birbaşa bağlantıya daha çox vaxt vermək üçün artırın; birbaşa bağlantının mümkün olmadığı şəbəkələrdə ötürücüyə daha tez keçmək üçün azaldın. Standart 2.5 saniyə üçün boş buraxın."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Запыт на абагульванне экрана на аддаленай прыладзе завяршыўся, не будучы выкананым"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk не змог атрымаць прыдатны экран ад XDG Desktop Portal, магчыма бібліятэка PipeWire занадта старая"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk не змог загрузіць кампанент GStreamer, патрэбны для захопу экрана ({})"),
|
||||
("Relay fallback delay in seconds", "Затрымка пераходу на рэтранслятар у секундах"),
|
||||
("relay-fallback-delay-tip", "Колькі часу ўжо ўсталяванае злучэнне праз рэтранслятар чакае прамога злучэння WebRTC, перш чым будзе выкарыстана замест яго. Павялічце, каб даць павольнаму прамому злучэнню больш часу; паменшыце, каб хутчэй пераходзіць на рэтранслятар у сетках, дзе прамое злучэнне немагчымае. Пакіньце пустым для значэння па змаўчанні 2.5 секунды."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Заявката за споделяне на екрана на отдалеченото устройство приключи, без да бъде изпълнена"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk не можа да получи използваем екран от XDG Desktop Portal, библиотеката PipeWire може да е твърде стара"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk не можа да зареди компонент на GStreamer, необходим за заснемане на екрана ({})"),
|
||||
("Relay fallback delay in seconds", "Забавяне преди преминаване към препредаване в секунди"),
|
||||
("relay-fallback-delay-tip", "Колко време вече установената връзка чрез препредаване изчаква директната WebRTC връзка, преди да бъде използвана вместо нея. Увеличете, за да дадете повече време на бавна директна връзка; намалете, за да се премине по-бързо към препредаване в мрежи, където директна връзка е невъзможна. Оставете празно за стойността по подразбиране 2.5 секунди."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "La sol·licitud de compartició de pantalla al dispositiu remot ha acabat sense completar-se"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "El RustDesk no ha pogut obtenir cap pantalla utilitzable de l'XDG Desktop Portal; la biblioteca PipeWire pot ser massa antiga"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "El RustDesk no ha pogut carregar un component del GStreamer necessari per capturar la pantalla ({})"),
|
||||
("Relay fallback delay in seconds", "Retard abans de recórrer al relé en segons"),
|
||||
("relay-fallback-delay-tip", "Quant de temps espera una connexió de relé ja establerta la connexió directa WebRTC abans d'utilitzar-se en lloc seu. Augmenteu-lo per donar més temps a una connexió directa lenta; reduïu-lo per passar abans al relé en xarxes on no es pot fer una connexió directa. Deixeu-lo buit per al valor predeterminat de 2.5 segons."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "远程设备上的屏幕共享请求已结束,但未完成"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk 无法从 XDG Desktop Portal 获取可用的屏幕,PipeWire 库可能过旧"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk 无法加载屏幕捕获所需的 GStreamer 组件 ({})"),
|
||||
("Relay fallback delay in seconds", "回落到中继前的等待时间(秒)"),
|
||||
("relay-fallback-delay-tip", "已经建立的中继连接会等待直连的 WebRTC 多久,超过这个时间就改用中继。调大可以让较慢的直连有更多机会胜出;调小则在无法直连的网络上更快回落到中继。留空表示使用默认值 2.5 秒。"),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Žádost o sdílení obrazovky na vzdáleném zařízení skončila, aniž by byla dokončena"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nezískal z XDG Desktop Portal použitelnou obrazovku, knihovna PipeWire může být příliš stará"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nemohl načíst komponentu GStreameru potřebnou k zachycení obrazovky ({})"),
|
||||
("Relay fallback delay in seconds", "Prodleva před přepnutím na přenos v sekundách"),
|
||||
("relay-fallback-delay-tip", "Jak dlouho již navázané spojení přes přenos čeká na přímé spojení WebRTC, než bude použito místo něj. Zvyšte, aby pomalé přímé spojení mělo více času uspět; snižte, aby se v sítích, kde přímé spojení není možné, dříve přešlo na přenos. Ponechte prázdné pro výchozí hodnotu 2.5 sekundy."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Anmodningen om skærmdeling på fjernenheden sluttede uden at blive gennemført"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk kunne ikke få en brugbar skærm fra XDG Desktop Portal, PipeWire-biblioteket er måske for gammelt"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk kunne ikke indlæse en GStreamer-komponent, der kræves til skærmoptagelse ({})"),
|
||||
("Relay fallback delay in seconds", "Forsinkelse før brug af relæ i sekunder"),
|
||||
("relay-fallback-delay-tip", "Hvor længe en allerede oprettet relæforbindelse venter på den direkte WebRTC-forbindelse, før den bruges i stedet. Forøg for at give en langsom direkte forbindelse mere tid; sænk for hurtigere at falde tilbage til relæet på netværk, hvor en direkte forbindelse ikke kan oprettes. Lad feltet stå tomt for standardværdien 2.5 sekunder."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Die Anfrage zur Bildschirmfreigabe auf dem entfernten Gerät endete, ohne abgeschlossen zu werden"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk konnte vom XDG Desktop Portal keinen nutzbaren Bildschirm erhalten, die PipeWire-Bibliothek ist möglicherweise zu alt"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk konnte eine für die Bildschirmaufnahme benötigte GStreamer-Komponente nicht laden ({})"),
|
||||
("Relay fallback delay in seconds", "Verzögerung bis zum Relais in Sekunden"),
|
||||
("relay-fallback-delay-tip", "Wie lange eine bereits aufgebaute Relaisverbindung auf die direkte WebRTC-Verbindung wartet, bevor sie stattdessen verwendet wird. Erhöhen Sie den Wert, um einer langsamen direkten Verbindung mehr Zeit zu geben; verringern Sie ihn, um in Netzwerken ohne mögliche Direktverbindung schneller auf das Relais zurückzufallen. Leer lassen für den Standardwert von 2.5 Sekunden."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Το αίτημα κοινής χρήσης οθόνης στην απομακρυσμένη συσκευή έληξε χωρίς να ολοκληρωθεί"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "Το RustDesk δεν μπόρεσε να λάβει αξιοποιήσιμη οθόνη από το XDG Desktop Portal, η βιβλιοθήκη PipeWire ίσως είναι πολύ παλιά"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "Το RustDesk δεν μπόρεσε να φορτώσει ένα στοιχείο του GStreamer που απαιτείται για την καταγραφή οθόνης ({})"),
|
||||
("Relay fallback delay in seconds", "Καθυστέρηση πριν από τη χρήση αναμεταδότη σε δευτερόλεπτα"),
|
||||
("relay-fallback-delay-tip", "Πόσο χρόνο περιμένει μια ήδη ενεργή σύνδεση αναμεταδότη την απευθείας σύνδεση WebRTC πριν χρησιμοποιηθεί στη θέση της. Αυξήστε το για να δώσετε σε μια αργή απευθείας σύνδεση περισσότερο χρόνο. Μειώστε το για ταχύτερη επιστροφή στον αναμεταδότη σε δίκτυα όπου δεν είναι δυνατή η απευθείας σύνδεση. Αφήστε το κενό για την προεπιλογή των 2.5 δευτερολέπτων."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -84,10 +84,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("android_input_permission_tip1", "In order for a remote device to control your Android device via mouse or touch, you need to allow RustDesk to use the \"Accessibility\" service."),
|
||||
("android_input_permission_tip2", "Please go to the next system settings page, find and enter [Installed Services], turn on [RustDesk Input] service."),
|
||||
("android_new_connection_tip", "New control request has been received, which wants to control your current device."),
|
||||
("android_service_will_start_tip", "Turning on \"Screen capture\" will automatically start the service, allowing other devices to request a connection to your device."),
|
||||
("android_service_will_start_tip", "Turning on \"Screen Capture\" will automatically start the service, allowing other devices to request a connection to your device."),
|
||||
("android_stop_service_tip", "Closing the service will automatically close all established connections."),
|
||||
("android_version_audio_tip", "The current Android version does not support audio capture, please upgrade to Android 10 or higher."),
|
||||
("android_start_service_tip", "Tap [Start service] or enable [Screen capture] permission to start the screen sharing service."),
|
||||
("android_start_service_tip", "Tap [Start service] or enable [Screen Capture] permission to start the screen sharing service."),
|
||||
("android_permission_may_not_change_tip", "Permissions for established connections may not be changed instantly until reconnected."),
|
||||
("doc_mac_permission", "https://rustdesk.com/docs/en/client/mac/#enable-permissions"),
|
||||
("Ignore Battery Optimizations", "Ignore battery optimizations"),
|
||||
@@ -252,11 +252,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("disable-udp-tip", "Controls whether to use TCP only.\nWhen this option enabled, RustDesk will not use UDP 21116 any more, TCP 21116 will be used instead."),
|
||||
("server-oss-not-support-tip", "NOTE: RustDesk server OSS doesn't include this feature."),
|
||||
("note-at-conn-end-tip", "Ask for note at end of connection"),
|
||||
("rel-mouse-not-supported-peer-tip", "Relative mouse mode is not supported by the connected peer."),
|
||||
("rel-mouse-not-ready-tip", "Relative mouse mode is not ready yet. Please try again."),
|
||||
("rel-mouse-lock-failed-tip", "Failed to lock cursor. Relative mouse mode has been disabled."),
|
||||
("rel-mouse-not-supported-peer-tip", "Relative Mouse Mode is not supported by the connected peer."),
|
||||
("rel-mouse-not-ready-tip", "Relative Mouse Mode is not ready yet. Please try again."),
|
||||
("rel-mouse-lock-failed-tip", "Failed to lock cursor. Relative Mouse Mode has been disabled."),
|
||||
("rel-mouse-exit-{}-tip", "Press {} to exit."),
|
||||
("rel-mouse-permission-lost-tip", "Keyboard permission was revoked. Relative mouse mode has been disabled."),
|
||||
("rel-mouse-permission-lost-tip", "Keyboard permission was revoked. Relative Mouse Mode has been disabled."),
|
||||
("keep-awake-during-outgoing-sessions-label", "Keep screen awake during outgoing sessions"),
|
||||
("keep-awake-during-incoming-sessions-label", "Keep screen awake during incoming sessions"),
|
||||
("password-hidden-tip", "Permanent password is set (hidden)."),
|
||||
@@ -278,6 +278,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("sync-clipboard-between-sessions-tip", "Text or images copied in one remote session are also sent to the clipboard of your other connected sessions."),
|
||||
("terminal-clipboard-write-tip", "An app in the terminal wants to copy text to this device's clipboard. If granted, this permission applies to terminal apps in all connections until you turn it off in Settings. Manual copy and paste are unaffected."),
|
||||
("port-forward-mux-tip", "Carry every connection of a port-forward mapping over a single connection to the peer, instead of connecting and logging in again for each one."),
|
||||
("relay-fallback-delay-tip", "How long a relay connection that is already up waits for the direct WebRTC connection before it is used instead. Raise it to give a slow direct connection more time to win; lower it to settle on the relay sooner on networks where a direct connection cannot be made. Leave empty for the default of 2.5 seconds."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "La peto pri ekrandividado sur la fora aparato finiĝis sen kompletiĝi"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk ne povis akiri uzeblan ekranon de XDG Desktop Portal, la biblioteko PipeWire eble estas tro malnova"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ne povis ŝargi komponanton de GStreamer necesan por ekrankapto ({})"),
|
||||
("Relay fallback delay in seconds", "Prokrasto antaŭ retransmisio en sekundoj"),
|
||||
("relay-fallback-delay-tip", "Kiom longe jam establita retransmisia konekto atendas la rektan WebRTC-konekton antaŭ ol esti uzata anstataŭe. Pligrandigu ĝin por doni al malrapida rekta konekto pli da tempo; malpligrandigu ĝin por pli frue uzi la retransmision en retoj kie rekta konekto ne eblas. Lasu malplena por la defaŭlta valoro de 2.5 sekundoj."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("android_service_will_start_tip", "Habilitar la captura de pantalla iniciará automáticamente el servicio, lo que permitirá que otros dispositivos soliciten una conexión desde este dispositivo."),
|
||||
("android_stop_service_tip", "Cerrar el servicio cerrará automáticamente todas las conexiones establecidas."),
|
||||
("android_version_audio_tip", "La versión actual de Android no admite la captura de audio, actualice a Android 10 o posterior."),
|
||||
("android_start_service_tip", "Toque [Iniciar Servicio] o conceda el permiso [Captura de pantalla] para iniciar el servicio de pantalla compartida."),
|
||||
("android_start_service_tip", "Toque [Iniciar servicio] o conceda el permiso [Captura de pantalla] para iniciar el servicio de pantalla compartida."),
|
||||
("android_permission_may_not_change_tip", "Es posible que los permisos de las conexiones ya establecidas no cambien de inmediato hasta que se vuelva a conectar."),
|
||||
("Account", "Cuenta"),
|
||||
("Overwrite", "Sobrescribir"),
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "La solicitud de compartir pantalla en el dispositivo remoto terminó sin completarse"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk no ha podido obtener una pantalla utilizable del XDG Desktop Portal; la biblioteca PipeWire puede ser demasiado antigua"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk no ha podido cargar un componente de GStreamer necesario para capturar la pantalla ({})"),
|
||||
("Relay fallback delay in seconds", "Retardo antes de usar el relé en segundos"),
|
||||
("relay-fallback-delay-tip", "Cuánto tiempo espera una conexión de relé ya establecida a la conexión directa WebRTC antes de usarse en su lugar. Auméntelo para dar más tiempo a una conexión directa lenta; redúzcalo para recurrir antes al relé en redes donde no es posible una conexión directa. Déjelo vacío para el valor predeterminado de 2.5 segundos."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enter your password", "Sisesta oma parool"),
|
||||
("Logging in...", "Sisselogimine..."),
|
||||
("Enable RDP session sharing", "Luba RDP-seansi jagamine"),
|
||||
("Auto Login", "Logi automaatselt sisse (Kehtib vaid valiku \"Lukusta pärast seansi lõppu\" lubamisel)"),
|
||||
("Auto Login", "Logi automaatselt sisse (Kehtib vaid valiku \"lukusta pärast seansi lõppu\" lubamisel)"),
|
||||
("Enable direct IP access", "Luba otsene IP-juurdepääs"),
|
||||
("Rename", "Nimeta ümber"),
|
||||
("Space", "Ruum"),
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Ekraani jagamise taotlus kaugseadmes lõppes ilma lõpule jõudmata"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk ei saanud XDG Desktop Portalilt kasutatavat ekraani, PipeWire'i teek võib olla liiga vana"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ei suutnud laadida ekraani jäädvustamiseks vajalikku GStreameri komponenti ({})"),
|
||||
("Relay fallback delay in seconds", "Viivitus enne relee kasutamist sekundites"),
|
||||
("relay-fallback-delay-tip", "Kui kaua juba loodud releeühendus ootab otsest WebRTC-ühendust, enne kui seda selle asemel kasutatakse. Suurendage, et anda aeglasele otseühendusele rohkem aega; vähendage, et võrkudes, kus otseühendust luua ei saa, releele kiiremini üle minna. Jätke tühjaks vaikeväärtuse 2.5 sekundit kasutamiseks."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Urruneko gailuko pantaila partekatzeko eskaera osatu gabe amaitu da"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk-ek ezin izan du pantaila erabilgarririk lortu XDG Desktop Portal-etik, PipeWire liburutegia zaharregia izan daiteke"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk-ek ezin izan du pantaila kapturatzeko beharrezkoa den GStreamer osagai bat kargatu ({})"),
|
||||
("Relay fallback delay in seconds", "Errelera itzultzeko atzerapena segundotan"),
|
||||
("relay-fallback-delay-tip", "Dagoeneko ezarritako errele-konexio batek WebRTC konexio zuzenari zenbat denbora itxaroten dion, haren ordez erabili aurretik. Handitu konexio zuzen motel bati denbora gehiago emateko; txikitu konexio zuzena egin ezin den sareetan lehenago errelera itzultzeko. Utzi hutsik 2.5 segundoko balio lehenetsirako."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "درخواست اشتراکگذاری صفحه در دستگاه راه دور بدون تکمیل شدن پایان یافت"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk نتوانست صفحهای قابل استفاده از XDG Desktop Portal دریافت کند، ممکن است کتابخانه PipeWire خیلی قدیمی باشد"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk نتوانست مؤلفه GStreamer موردنیاز برای ضبط صفحه را بارگذاری کند ({})"),
|
||||
("Relay fallback delay in seconds", "تأخیر بازگشت به رله بر حسب ثانیه"),
|
||||
("relay-fallback-delay-tip", "یک اتصال رله که از قبل برقرار شده چقدر منتظر اتصال مستقیم WebRTC می ماند پیش از آنکه به جای آن استفاده شود. آن را افزایش دهید تا به اتصال مستقیم کند فرصت بیشتری داده شود؛ کاهش دهید تا در شبکه هایی که اتصال مستقیم ممکن نیست، زودتر به رله بازگردد. برای مقدار پیش فرض 2.5 ثانیه خالی بگذارید."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Näytön jakamispyyntö etälaitteessa päättyi ilman että se saatiin valmiiksi"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk ei saanut XDG Desktop Portalilta käyttökelpoista näyttöä, PipeWire-kirjasto voi olla liian vanha"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ei voinut ladata näytön kaappaukseen tarvittavaa GStreamer-osaa ({})"),
|
||||
("Relay fallback delay in seconds", "Viive ennen välitykseen siirtymistä sekunteina"),
|
||||
("relay-fallback-delay-tip", "Kuinka kauan jo muodostettu välitysyhteys odottaa suoraa WebRTC-yhteyttä ennen kuin sitä käytetään sen sijaan. Kasvata arvoa antaaksesi hitaalle suoralle yhteydelle enemmän aikaa; pienennä sitä siirtyäksesi nopeammin välitykseen verkoissa, joissa suoraa yhteyttä ei voi muodostaa. Jätä tyhjäksi käyttääksesi oletusarvoa 2.5 sekuntia."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "La demande de partage d'écran sur l'appareil distant s'est terminée sans aboutir"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk n'a pas pu obtenir d'écran exploitable auprès du XDG Desktop Portal, la bibliothèque PipeWire est peut-être trop ancienne"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk n'a pas pu charger un composant GStreamer nécessaire à la capture d'écran ({})"),
|
||||
("Relay fallback delay in seconds", "Délai avant bascule vers le relais en secondes"),
|
||||
("relay-fallback-delay-tip", "Durée pendant laquelle une connexion relais déjà établie attend la connexion directe WebRTC avant d'être utilisée à sa place. Augmentez-la pour laisser plus de temps à une connexion directe lente ; diminuez-la pour basculer plus tôt vers le relais sur les réseaux où une connexion directe est impossible. Laissez vide pour la valeur par défaut de 2.5 secondes."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "ეკრანის გაზიარების მოთხოვნა დისტანციურ მოწყობილობაზე დასრულდა შეუსრულებლად"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk-მა ვერ მიიღო გამოსადეგი ეკრანი XDG Desktop Portal-იდან, PipeWire-ის ბიბლიოთეკა შესაძლოა ძალიან ძველია"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk-მა ვერ ჩატვირთა ეკრანის ჩაწერისთვის საჭირო GStreamer-ის კომპონენტი ({})"),
|
||||
("Relay fallback delay in seconds", "რელეზე გადასვლის დაყოვნება წამებში"),
|
||||
("relay-fallback-delay-tip", "რამდენ ხანს ელოდება უკვე დამყარებული რელე-კავშირი პირდაპირ WebRTC კავშირს, სანამ მის ნაცვლად გამოიყენება. გაზარდეთ, რომ ნელ პირდაპირ კავშირს მეტი დრო მისცეთ; შეამცირეთ, რომ ქსელებში, სადაც პირდაპირი კავშირი შეუძლებელია, უფრო სწრაფად გადავიდეს რელეზე. დატოვეთ ცარიელი ნაგულისხმევი 2.5 წამისთვის."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "A solicitude de compartir pantalla no dispositivo remoto rematou sen completarse"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk non puido obter unha pantalla utilizable do XDG Desktop Portal, a biblioteca PipeWire pode ser demasiado antiga"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk non puido cargar un compoñente de GStreamer necesario para capturar a pantalla ({})"),
|
||||
("Relay fallback delay in seconds", "Atraso antes de usar o relé en segundos"),
|
||||
("relay-fallback-delay-tip", "Canto tempo agarda unha conexión de relé xa establecida pola conexión directa WebRTC antes de usarse no seu lugar. Auménteo para darlle máis tempo a unha conexión directa lenta; redúzao para recorrer antes ao relé en redes onde non é posible unha conexión directa. Déixeo baleiro para o valor predeterminado de 2.5 segundos."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "રિમોટ ઉપકરણ પર સ્ક્રીન શેરિંગ વિનંતી પૂર્ણ થયા વિના સમાપ્ત થઈ"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk XDG Desktop Portal પાસેથી ઉપયોગી સ્ક્રીન મેળવી શક્યું નથી, PipeWire લાઇબ્રેરી કદાચ ઘણી જૂની છે"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk સ્ક્રીન કૅપ્ચર માટે જરૂરી GStreamer ઘટક લોડ કરી શક્યું નથી ({})"),
|
||||
("Relay fallback delay in seconds", "રિલે પર પાછા ફરવામાં વિલંબ સેકન્ડમાં"),
|
||||
("relay-fallback-delay-tip", "પહેલેથી સ્થાપિત રિલે કનેક્શન સીધા WebRTC કનેક્શનની કેટલો સમય રાહ જુએ છે, ત્યાર બાદ તેના બદલે વપરાય છે. ધીમા સીધા કનેક્શનને વધુ સમય આપવા માટે વધારો; જ્યાં સીધું કનેક્શન શક્ય નથી તેવા નેટવર્ક પર વહેલા રિલે પર જવા માટે ઘટાડો. મૂળભૂત 2.5 સેકન્ડ માટે ખાલી રાખો."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "בקשת שיתוף המסך במכשיר המרוחק הסתיימה מבלי להתבצע"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk לא הצליח לקבל מסך שמיש מ-XDG Desktop Portal, ייתכן שספריית PipeWire ישנה מדי"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk לא הצליח לטעון רכיב GStreamer הדרוש ללכידת מסך ({})"),
|
||||
("Relay fallback delay in seconds", "השהיה לפני מעבר לממסר בשניות"),
|
||||
("relay-fallback-delay-tip", "כמה זמן חיבור ממסר שכבר נוצר ממתין לחיבור WebRTC הישיר לפני שישמש במקומו. הגדל כדי לתת לחיבור ישיר איטי יותר זמן; הקטן כדי לעבור מהר יותר לממסר ברשתות שבהן לא ניתן ליצור חיבור ישיר. השאר ריק לערך ברירת המחדל של 2.5 שניות."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "रिमोट डिवाइस पर स्क्रीन शेयरिंग अनुरोध पूरा हुए बिना समाप्त हो गया"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk XDG Desktop Portal से उपयोग योग्य स्क्रीन प्राप्त नहीं कर सका, PipeWire लाइब्रेरी बहुत पुरानी हो सकती है"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk स्क्रीन कैप्चर के लिए आवश्यक GStreamer घटक लोड नहीं कर सका ({})"),
|
||||
("Relay fallback delay in seconds", "रिले पर लौटने में विलंब सेकंड में"),
|
||||
("relay-fallback-delay-tip", "पहले से स्थापित रिले कनेक्शन सीधे WebRTC कनेक्शन की कितनी देर प्रतीक्षा करता है, उसके बाद उसके स्थान पर उपयोग किया जाता है। धीमे सीधे कनेक्शन को अधिक समय देने के लिए बढ़ाएँ; जिन नेटवर्क पर सीधा कनेक्शन संभव नहीं है वहाँ जल्दी रिले पर जाने के लिए घटाएँ। डिफ़ॉल्ट 2.5 सेकंड के लिए खाली छोड़ें।"),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Zahtjev za dijeljenje zaslona na udaljenom uređaju završio je bez dovršetka"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nije mogao dobiti upotrebljiv zaslon od XDG Desktop Portala, PipeWire biblioteka je možda prestara"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nije mogao učitati GStreamer komponentu potrebnu za snimanje zaslona ({})"),
|
||||
("Relay fallback delay in seconds", "Odgoda prije prelaska na relej u sekundama"),
|
||||
("relay-fallback-delay-tip", "Koliko dugo već uspostavljena relejna veza čeka izravnu WebRTC vezu prije nego što se upotrijebi umjesto nje. Povećajte da sporoj izravnoj vezi date više vremena; smanjite da se na mrežama gdje izravna veza nije moguća brže prijeđe na relej. Ostavite prazno za zadanu vrijednost od 2.5 sekunde."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "A képernyőmegosztási kérés a távoli eszközön befejeződött anélkül, hogy teljesült volna"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "A RustDesk nem kapott használható képernyőt az XDG Desktop Portaltól, a PipeWire programkönyvtár túl régi lehet"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "A RustDesk nem tudta betölteni a képernyőrögzítéshez szükséges GStreamer összetevőt ({})"),
|
||||
("Relay fallback delay in seconds", "Késleltetés a továbbítóra váltás előtt másodpercben"),
|
||||
("relay-fallback-delay-tip", "Mennyi ideig vár a már létrejött továbbító kapcsolat a közvetlen WebRTC kapcsolatra, mielőtt helyette használnák. Növelje, hogy a lassú közvetlen kapcsolatnak több ideje legyen; csökkentse, hogy olyan hálózatokon, ahol közvetlen kapcsolat nem hozható létre, hamarabb váltson továbbítóra. Hagyja üresen az alapértelmezett 2.5 másodperchez."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -716,11 +716,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("note-at-conn-end-tip", "Minta catatan di akhir koneksi"),
|
||||
("Show terminal extra keys", "Tampilkan tombol tambahan terminal"),
|
||||
("Relative mouse mode", "Mode mouse relatif"),
|
||||
("rel-mouse-not-supported-peer-tip", "Mode mouse relatif tidak didukung oleh peer yang terhubung."),
|
||||
("rel-mouse-not-ready-tip", "Mode mouse relatif belum siap. Silakan coba lagi."),
|
||||
("rel-mouse-lock-failed-tip", "Gagal mengunci kursor. Mode mouse relatif telah dinonaktifkan."),
|
||||
("rel-mouse-not-supported-peer-tip", "Mode Mouse Relatif tidak didukung oleh peer yang terhubung."),
|
||||
("rel-mouse-not-ready-tip", "Mode Mouse Relatif belum siap. Silakan coba lagi."),
|
||||
("rel-mouse-lock-failed-tip", "Gagal mengunci kursor. Mode Mouse Relatif telah dinonaktifkan."),
|
||||
("rel-mouse-exit-{}-tip", "Tekan {} untuk keluar."),
|
||||
("rel-mouse-permission-lost-tip", "Izin keyboard dicabut. Mode mouse relatif telah dinonaktifkan."),
|
||||
("rel-mouse-permission-lost-tip", "Izin keyboard dicabut. Mode Mouse Relatif telah dinonaktifkan."),
|
||||
("Changelog", "Catatan perubahan"),
|
||||
("keep-awake-during-outgoing-sessions-label", "Jaga layar tetap menyala selama sesi keluar"),
|
||||
("keep-awake-during-incoming-sessions-label", "Jaga layar tetap menyala selama sesi masuk"),
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Permintaan berbagi layar di perangkat jarak jauh berakhir tanpa diselesaikan"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk tidak mendapatkan layar yang dapat digunakan dari XDG Desktop Portal, pustaka PipeWire mungkin terlalu lama"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk tidak dapat memuat komponen GStreamer yang diperlukan untuk merekam layar ({})"),
|
||||
("Relay fallback delay in seconds", "Jeda sebelum beralih ke relai dalam detik"),
|
||||
("relay-fallback-delay-tip", "Berapa lama koneksi relai yang sudah terbentuk menunggu koneksi langsung WebRTC sebelum digunakan sebagai gantinya. Perbesar untuk memberi koneksi langsung yang lambat lebih banyak waktu; perkecil agar lebih cepat beralih ke relai pada jaringan yang tidak memungkinkan koneksi langsung. Biarkan kosong untuk nilai bawaan 2.5 detik."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -541,7 +541,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Plug out all", "Scollega tutto"),
|
||||
("True color (4:4:4)", "Colore reale (4:4:4)"),
|
||||
("Enable blocking user input", "Abilita blocco input utente"),
|
||||
("id_input_tip", "Puoi inserire un ID, un IP diretto o un dominio con una porta (<dominio>:<porta>).\nSe vuoi accedere a un dispositivo in un altro server, aggiungi l'indirizzo del server (<id>@<indirizzo_server >?key=<valore_chiave>), ad esempio\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nSe vuoi accedere as un dispositivo in un server pubblico, inserisci \"<id>@public\", per il server pubblico la chiave non è necessaria.\n\nSe vuoi forzare l'uso di una connessione di inoltro alla prima connessione, aggiungi \"/r\" alla fine dell'ID, ad esempio \"9123456234/r\"."),
|
||||
("id_input_tip", "Puoi inserire un ID, un IP diretto o un dominio con una porta (<dominio>:<porta>).\nSe vuoi accedere as un dispositivo in un altro server, aggiungi l'indirizzo del server (<id>@<indirizzo_server >?key=<valore_chiave>), ad esempio\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nSe vuoi accedere as un dispositivo in un server pubblico, inserisci \"<id>@public\", per il server pubblico la chiave non è necessaria\n\nSe vuoi forzare l'uso di una connessione di inoltro alla prima connessione, aggiungi \"/r\" alla fine dell'ID, ad esempio \"9123456234/r\"."),
|
||||
("privacy_mode_impl_mag_tip", "Modo 1"),
|
||||
("privacy_mode_impl_virtual_display_tip", "Modo 2"),
|
||||
("Enter privacy mode", "Entra in modalità privacy"),
|
||||
@@ -622,7 +622,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Parent directory", "Cartella principale"),
|
||||
("Resume", "Riprendi"),
|
||||
("Invalid file name", "Nome file non valido"),
|
||||
("one-way-file-transfer-tip", "Sul lato controllato è abilitato il trasferimento file unidirezionale."),
|
||||
("one-way-file-transfer-tip", "Sul lato controllato è abilitato il trasferimento file unidirezionale."),
|
||||
("Authentication Required", "Richiesta autenticazione"),
|
||||
("Authenticate", "Autentica"),
|
||||
("web_id_input_tip", "È possibile inserire un ID nello stesso server, nel client web non è supportato l'accesso con IP diretto.\nSe vuoi accedere ad un dispositivo in un altro server, aggiungi l'indirizzo del server (<id>@<indirizzo_server>?key=<valore_chiave >), ad esempio,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nSe vuoi accedere ad un dispositivo in un server pubblico, inserisci \"<id>@public\", la chiave non è necessaria per il server pubblico."),
|
||||
@@ -676,7 +676,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Default trackpad speed", "Velocità predefinita trackpad"),
|
||||
("Numeric one-time password", "Password numerica monouso"),
|
||||
("Enable IPv6 P2P connection", "Abilita connessione P2P IPv6"),
|
||||
("Enable UDP hole punching", "Abilita hole punching UDP"),
|
||||
("Enable UDP hole punching", "Abilita hole punching UDP"),
|
||||
("View camera", "Visualizza telecamera"),
|
||||
("Enable camera", "Abilita camera"),
|
||||
("No cameras", "Nessuna camera"),
|
||||
@@ -769,17 +769,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Reuse one connection for port forwarding", "Per l'inoltro delle porte riusa una sola connessione "),
|
||||
("port-forward-mux-tip", "Fa passare tutte le connessioni di un inoltro porte in un'unica connessione verso il dispositivo remoto, invece di connettersi e autenticarsi di nuovo per ognuna."),
|
||||
("Enable WebRTC P2P connection", "Abilita connessione P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Abilita hole punching TCP"),
|
||||
("The screen sharing request was declined on the remote device", "La richiesta di condivisione dello schermo nel dispositivo remoto è stata rifiutata"),
|
||||
("The screen sharing request timed out on the remote device", "La richiesta di condivisione dello schermo nel dispositivo remoto è scaduta"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk non riesce a raggiungere la sessione desktop nel dispositivo remoto, controlla che sia in esecuzione una sessione desktop e che RustDesk possa usarla"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Nel portale desktop nel dispositivo remoto manca una funzionalità necessaria per la condivisione dello schermo o il controllo remoto, il relativo backend potrebbe non essere installato"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "La condivisione dello schermo è stata approvata nel dispositivo remoto, ma non è stato possibile aprire la connessione PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "La richiesta di condivisione dello schermo si è chiusa senza essere completata nel dispositivo remoto"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk non è riuscito a ottenere una schermata usabile dal portale desktop XDG, la libreria PipeWire potrebbe essere troppo vecchia"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk non è riuscito a caricare un componente GStreamer necessario per l'acquisizione dello schermo ({})"),
|
||||
("Relay fallback delay in seconds", "Ritardo fallback relay (secondi)"),
|
||||
("relay-fallback-delay-tip", "Quanto tempo una connessione relay già attiva attende la connessione WebRTC diretta prima di essere usata. Aumentalo per dare a una connessione diretta lenta più tempo per funzionare; diminuiscilo per passare prima al relay sulle reti in cui non è possibile effettuare una connessione diretta. Lascia vuoto per il valore predefinito di 2,5 secondi."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "Per avviare una chiamata vocale, attiva nella pagina 'Condivisione schermo' la voce 'Cattura audio'.")
|
||||
("Enable TCP hole punching", "Abilita hole punching TCP"),
|
||||
("The screen sharing request was declined on the remote device", ""),
|
||||
("The screen sharing request timed out on the remote device", ""),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", ""),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", ""),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", ""),
|
||||
("The screen sharing request ended without completing on the remote device", ""),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", ""),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "リモート端末での画面共有の要求は完了しないまま終了しました"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk は XDG Desktop Portal から使用可能な画面を取得できませんでした。PipeWire ライブラリが古すぎる可能性があります"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk は画面キャプチャに必要な GStreamer コンポーネントを読み込めませんでした ({})"),
|
||||
("Relay fallback delay in seconds", "中継に切り替えるまでの待ち時間 (秒)"),
|
||||
("relay-fallback-delay-tip", "すでに確立された中継接続が、直接の WebRTC 接続をどれだけ待ってから代わりに使用されるかを指定します。値を大きくすると遅い直接接続に時間を与えられ、小さくすると直接接続できないネットワークで早く中継に切り替わります。空欄にすると既定値の 2.5 秒になります。"),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -759,17 +759,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("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.", "브라우저가 열리지 않았나요? 아래 URL을 사용하여 로그인하세요."),
|
||||
("Browser didn't open? Use the url below to sign in.", "브라우저가 열리지 않았나요? 아래 URL로 로그인하세요."),
|
||||
("Lock canvas", "캔버스 잠금"),
|
||||
("Sync clipboard between sessions", "세션 간 클립보드 동기화"),
|
||||
("sync-clipboard-between-sessions-tip", "원격 세션 하나에서 복사한 텍스트나 이미지는 다른 연결된 세션의 클립보드로도 전송됩니다."),
|
||||
("terminal-clipboard-write-tip", "터미널의 앱이 이 장치의 클립보드에 텍스트를 복사하려고 합니다. 이 권한이 부여된 경우, 설정에서 이를 끌 때까지 모든 연결의 터미널 앱에 적용됩니다. 수동 복사 및 붙여넣기는 영향을 받지 않습니다."),
|
||||
("Allow terminal apps to copy to clipboard", "터미널 앱이 클립보드로 복사하도록 허용"),
|
||||
("sync-clipboard-between-sessions-tip", "하나의 원격 세션에서 복사한 텍스트나 이미지는 연결된 다른 세션의 클립보드에도 전송됩니다."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "활성화"),
|
||||
("Reuse one connection for port forwarding", "포트 포워딩을 위해 하나의 연결을 재사용"),
|
||||
("port-forward-mux-tip", "각 연결마다 다시 연결하고 로그인할 필요 없이, 포트 포워딩 매핑의 모든 연결을 피어에 대한 단일 연결로 전달합니다."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P 연결 사용함"),
|
||||
("Enable TCP hole punching", "TCP 홀 펀칭 사용함"),
|
||||
("Reuse one connection for port forwarding", "포트 포워딩에 연결 하나를 재사용"),
|
||||
("port-forward-mux-tip", "포트 포워딩 하나의 모든 연결을 상대방과의 단일 연결로 전달합니다. 연결마다 다시 접속하고 로그인하지 않습니다."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P 연결 사용"),
|
||||
("Enable TCP hole punching", "TCP 홀 펀칭 사용"),
|
||||
("The screen sharing request was declined on the remote device", "원격 장치에서 화면 공유 요청이 거부되었습니다"),
|
||||
("The screen sharing request timed out on the remote device", "원격 장치에서 화면 공유 요청이 시간 초과되었습니다"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk가 원격 장치의 데스크톱 세션에 접근할 수 없습니다. 세션이 실행 중이고 RustDesk가 사용할 수 있는지 확인하세요"),
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "원격 장치의 화면 공유 요청이 완료되지 않은 채 종료되었습니다"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk가 XDG Desktop Portal에서 사용 가능한 화면을 가져오지 못했습니다. PipeWire 라이브러리가 너무 오래되었을 수 있습니다"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk가 화면 캡처에 필요한 GStreamer 구성 요소를 불러오지 못했습니다 ({})"),
|
||||
("Relay fallback delay in seconds", "릴레이 대체 작동 지연 시간 (초)"),
|
||||
("relay-fallback-delay-tip", "이미 연결된 중계 연결이 직접 WebRTC 연결을 얼마나 기다린 후 대신 사용되는지입니다. 값을 늘리면 느린 직접 연결에 더 많은 시간을 주고, 줄이면 직접 연결이 불가능한 네트워크에서 더 빨리 중계로 전환합니다. 비워 두면 기본값 2.5초가 사용됩니다."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "음성 통화를 시작하려면 '화면 공유' 페이지에서 '오디오 캡처'를 사용함으로 하세요.")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Қашықтағы құрылғыдағы экранды бөлісу сұрауы аяқталмай тоқтады"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk XDG Desktop Portal-дан жарамды экран ала алмады, PipeWire кітапханасы тым ескі болуы мүмкін"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk экранды түсіру үшін қажет GStreamer компонентін жүктей алмады ({})"),
|
||||
("Relay fallback delay in seconds", "Релеге ауысу кідірісі, секундпен"),
|
||||
("relay-fallback-delay-tip", "Бұрыннан орнатылған реле байланысы тікелей WebRTC байланысын қанша уақыт күтеді, содан кейін оның орнына қолданылады. Баяу тікелей байланысқа көбірек уақыт беру үшін үлкейтіңіз; тікелей байланыс мүмкін емес желілерде релеге тезірек ауысу үшін кішірейтіңіз. Әдепкі 2.5 секунд үшін бос қалдырыңыз."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Ekrano bendrinimo užklausa nuotoliniame įrenginyje baigėsi jos neužbaigus"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk negavo tinkamo ekrano iš XDG Desktop Portal, PipeWire biblioteka gali būti per sena"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nepavyko įkelti ekrano įrašymui reikalingo GStreamer komponento ({})"),
|
||||
("Relay fallback delay in seconds", "Delsa prieš pereinant prie perdavimo sekundėmis"),
|
||||
("relay-fallback-delay-tip", "Kiek laiko jau užmegztas perdavimo ryšys laukia tiesioginio WebRTC ryšio, kol bus panaudotas vietoj jo. Padidinkite, kad lėtam tiesioginiam ryšiui būtų skirta daugiau laiko; sumažinkite, kad tinkluose, kuriuose tiesioginis ryšys neįmanomas, greičiau būtų pereinama prie perdavimo. Palikite tuščią numatytajai 2.5 sekundės reikšmei."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Ekrāna koplietošanas pieprasījums attālinātajā ierīcē beidzās, netiekot pabeigts"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk neieguva izmantojamu ekrānu no XDG Desktop Portal, PipeWire bibliotēka var būt pārāk veca"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nevarēja ielādēt ekrāna tveršanai nepieciešamo GStreamer komponentu ({})"),
|
||||
("Relay fallback delay in seconds", "Aizkave pirms pārslēgšanās uz retranslatoru sekundēs"),
|
||||
("relay-fallback-delay-tip", "Cik ilgi jau izveidots retranslatora savienojums gaida tiešo WebRTC savienojumu, pirms tiek izmantots tā vietā. Palieliniet, lai lēnam tiešajam savienojumam dotu vairāk laika; samaziniet, lai tīklos, kur tiešais savienojums nav iespējams, ātrāk pārslēgtos uz retranslatoru. Atstājiet tukšu noklusējuma 2.5 sekunžu vērtībai."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "വിദൂര ഉപകരണത്തിലെ സ്ക്രീൻ പങ്കിടൽ അഭ്യർത്ഥന പൂർത്തിയാകാതെ അവസാനിച്ചു"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "XDG Desktop Portal-ൽ നിന്ന് ഉപയോഗയോഗ്യമായ സ്ക്രീൻ RustDesk-ന് ലഭിച്ചില്ല, PipeWire ലൈബ്രറി വളരെ പഴയതാകാം"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "സ്ക്രീൻ പകർത്താൻ ആവശ്യമായ GStreamer ഘടകം RustDesk-ന് ലോഡ് ചെയ്യാനായില്ല ({})"),
|
||||
("Relay fallback delay in seconds", "റിലേയിലേക്ക് മാറുന്നതിനുള്ള കാലതാമസം സെക്കൻഡിൽ"),
|
||||
("relay-fallback-delay-tip", "ഇതിനകം സ്ഥാപിതമായ റിലേ കണക്ഷൻ നേരിട്ടുള്ള WebRTC കണക്ഷനായി എത്ര നേരം കാത്തിരിക്കുന്നു, അതിനുശേഷം അതിനുപകരം ഉപയോഗിക്കുന്നു. മന്ദഗതിയിലുള്ള നേരിട്ടുള്ള കണക്ഷന് കൂടുതൽ സമയം നൽകാൻ വർദ്ധിപ്പിക്കുക; നേരിട്ടുള്ള കണക്ഷൻ സാധ്യമല്ലാത്ത നെറ്റ്വർക്കുകളിൽ വേഗത്തിൽ റിലേയിലേക്ക് മാറാൻ കുറയ്ക്കുക. സ്ഥിരസ്ഥിതിയായ 2.5 സെക്കൻഡിനായി ശൂന്യമാക്കിയിടുക."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Forespørselen om skjermdeling på den eksterne enheten ble avsluttet uten å bli fullført"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk fikk ingen brukbar skjerm fra XDG Desktop Portal, PipeWire-biblioteket kan være for gammelt"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk kunne ikke laste en GStreamer-komponent som kreves for skjermopptak ({})"),
|
||||
("Relay fallback delay in seconds", "Forsinkelse før bruk av relé i sekunder"),
|
||||
("relay-fallback-delay-tip", "Hvor lenge en allerede opprettet reléforbindelse venter på den direkte WebRTC-forbindelsen før den brukes i stedet. Øk verdien for å gi en treg direkteforbindelse mer tid; senk den for å gå raskere over til reléet på nettverk der direkte forbindelse ikke er mulig. La stå tom for standardverdien på 2.5 sekunder."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Het verzoek om schermdeling op het externe apparaat is geëindigd zonder te zijn voltooid"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk kon geen bruikbaar scherm verkrijgen van de XDG Desktop Portal, de PipeWire-bibliotheek is mogelijk te oud"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk kon een GStreamer-component die nodig is voor schermopname niet laden ({})"),
|
||||
("Relay fallback delay in seconds", "Vertraging voordat relay wordt gebruikt in seconden"),
|
||||
("relay-fallback-delay-tip", "Hoe lang een al tot stand gekomen relayverbinding wacht op de directe WebRTC-verbinding voordat deze in plaats daarvan wordt gebruikt. Verhoog de waarde om een trage directe verbinding meer tijd te geven; verlaag deze om op netwerken waar een directe verbinding niet mogelijk is sneller op de relay terug te vallen. Laat leeg voor de standaardwaarde van 2.5 seconden."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Żądanie udostępnienia ekranu na urządzeniu zdalnym zakończyło się bez ukończenia"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nie uzyskał użytecznego ekranu z XDG Desktop Portal, biblioteka PipeWire może być zbyt stara"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nie mógł załadować składnika GStreamer wymaganego do przechwytywania ekranu ({})"),
|
||||
("Relay fallback delay in seconds", "Opóźnienie przed przejściem na przekaźnik w sekundach"),
|
||||
("relay-fallback-delay-tip", "Jak długo nawiązane już połączenie przez przekaźnik czeka na bezpośrednie połączenie WebRTC, zanim zostanie użyte zamiast niego. Zwiększ, aby dać wolnemu połączeniu bezpośredniemu więcej czasu; zmniejsz, aby w sieciach, w których połączenie bezpośrednie jest niemożliwe, szybciej przechodzić na przekaźnik. Pozostaw puste, aby użyć wartości domyślnej 2.5 sekundy."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "O pedido de partilha de ecrã no dispositivo remoto terminou sem ser concluído"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "O RustDesk não conseguiu obter um ecrã utilizável do XDG Desktop Portal, a biblioteca PipeWire pode ser demasiado antiga"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "O RustDesk não conseguiu carregar um componente do GStreamer necessário para capturar o ecrã ({})"),
|
||||
("Relay fallback delay in seconds", "Atraso antes de recorrer ao retransmissor em segundos"),
|
||||
("relay-fallback-delay-tip", "Quanto tempo uma ligação de retransmissão já estabelecida aguarda pela ligação direta WebRTC antes de ser usada em vez dela. Aumente para dar mais tempo a uma ligação direta lenta; diminua para recorrer mais cedo ao retransmissor em redes onde não é possível uma ligação direta. Deixe vazio para o valor predefinido de 2.5 segundos."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Click to upgrade", "Iniciar atualização"),
|
||||
("Configure", "Configurar"),
|
||||
("config_acc", "Para controlar seu computador remotamente, você precisa conceder ao RustDesk permissões de \"Acessibilidade\"."),
|
||||
("config_screen", "Para acessar seu computador remotamente, você precisa conceder ao RustDesk permissões de \"Gravar a Tela\""),
|
||||
("config_screen", "Para acessar seu computador remotamente, você precisa conceder ao RustDesk permissões de \"Gravar a Tela\"/"),
|
||||
("Installing ...", "Instalando ..."),
|
||||
("Install", "Instalar"),
|
||||
("Installation", "Instalação"),
|
||||
@@ -282,7 +282,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("android_service_will_start_tip", "Habilitar a Captura de Tela irá automaticamente inicalizar o serviço, permitindo que outros dispositivos solicitem uma conexão deste dispositivo."),
|
||||
("android_stop_service_tip", "Fechar o serviço irá automaticamente fechar todas as conexões estabelecidas."),
|
||||
("android_version_audio_tip", "A versão atual do Android não suporta captura de áudio, por favor atualize para o Android 10 ou superior."),
|
||||
("android_start_service_tip", "Toque em [Iniciar Serviço] ou habilite a permissão [Captura de Tela] para iniciar o serviço de compartilhamento de tela."),
|
||||
("android_start_service_tip", "Toque em [Iniciar serviço] ou habilite a permissão [Captura de tela] para iniciar o serviço de compartilhamento de tela."),
|
||||
("android_permission_may_not_change_tip", "As permissões para conexões estabelecidas podem não ser alteradas instantaneamente até que seja reconectado."),
|
||||
("Account", "Conta"),
|
||||
("Overwrite", "Substituir"),
|
||||
@@ -691,7 +691,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Incorrect username or password.", "Usuário ou senha incorretos"),
|
||||
("The user is not an administrator.", "O usuário não é administrador"),
|
||||
("Failed to check if the user is an administrator.", "Falha ao verificar se o usuário é administrador"),
|
||||
("Supported only in the installed version.", "Suportado somente na versão instalada"),
|
||||
("Supported only in the installed version.", "Funciona somente na versão instalada"),
|
||||
("elevation_username_tip", "Insira o nome do usuário ou domínio\\usuário"),
|
||||
("Preparing for installation ...", "Preparando para instalação ..."),
|
||||
("Show my cursor", "Mostrar meu cursor"),
|
||||
@@ -763,23 +763,20 @@ 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."),
|
||||
("terminal-clipboard-write-tip", "Aplicativos do terminal podem copiar para a área de transferência"),
|
||||
("Allow terminal apps to copy to clipboard", "Permitir cópia do terminal para a área de transferência"),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Habilitar"),
|
||||
("Reuse one connection for port forwarding", "Reutilizar uma conexão para encaminhamento de portas"),
|
||||
("port-forward-mux-tip", "Levar todas as conexões de um encaminhamento de portas por uma única conexão com o outro computador, em vez de estabelecer uma nova conexão e fazer login novamente para cada uma."),
|
||||
("port-forward-mux-tip", "Levar todas as conexões de um encaminhamento de portas por uma única conexão com o outro computador, em vez de conectar e fazer login novamente para cada uma."),
|
||||
("Enable WebRTC P2P connection", "Habilitar conexão WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Habilitar TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "A solicitação de compartilhamento de tela foi recusada no dispositivo remoto"),
|
||||
("The screen sharing request timed out on the remote device", "A solicitação de compartilhamento de tela expirou no dispositivo remoto"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "O RustDesk não consegue acessar a sessão da área de trabalho no dispositivo remoto, verifique se há uma sessão da área de trabalho em execução e se o RustDesk pode acessá-la."),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "O portal da área de trabalho no dispositivo remoto não possui um recurso necessário para o compartilhamento de tela ou controle remoto. O backend pode não estar instalado."),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "O compartilhamento de tela foi autorizado no dispositivo remoto, mas não foi possível abrir a conexão com o PipeWire."),
|
||||
("The screen sharing request ended without completing on the remote device", "A solicitação de compartilhamento de tela no dispositivo remoto foi encerrada sem ser concluída."),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "O RustDesk não conseguiu obter uma tela utilizável do XDG Desktop Portal. A biblioteca do PipeWire pode estar desatualizada."),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "O RustDesk não conseguiu carregar um componente do GStreamer necessário para a captura de tela ({})."),
|
||||
("Relay fallback delay in seconds", "Atraso antes de recorrer ao retransmissor em segundos"),
|
||||
("relay-fallback-delay-tip", "Tempo que a conexão de retransmissão aguarda pela conexão direta WebRTC. Aumente para dar mais tempo a conexões lentas; diminua para usar o retransmissor mais cedo. Deixe vazio para usar o padrão de 2,5 segundos."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "Para iniciar uma chamada de voz, ative \"Captura de áudio\" na página \"Compartilhamento de tela\".")
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "O RustDesk não consegue acessar a sessão de área de trabalho no dispositivo remoto, verifique se há uma sessão em execução e se o RustDesk pode usá-la"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Falta ao portal de área de trabalho do dispositivo remoto um recurso necessário para compartilhamento de tela ou controle remoto, seu backend pode não estar instalado"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "O compartilhamento de tela foi aprovado no dispositivo remoto, mas não foi possível abrir a conexão PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "A solicitação de compartilhamento de tela no dispositivo remoto terminou sem ser concluída"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "O RustDesk não conseguiu obter uma tela utilizável do XDG Desktop Portal, a biblioteca PipeWire pode ser muito antiga"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "O RustDesk não conseguiu carregar um componente do GStreamer necessário para capturar a tela ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Cererea de partajare a ecranului pe dispozitivul de la distanță s-a încheiat fără a fi finalizată"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nu a putut obține un ecran utilizabil de la XDG Desktop Portal, biblioteca PipeWire poate fi prea veche"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nu a putut încărca o componentă GStreamer necesară pentru capturarea ecranului ({})"),
|
||||
("Relay fallback delay in seconds", "Întârziere înainte de trecerea la releu în secunde"),
|
||||
("relay-fallback-delay-tip", "Cât timp așteaptă o conexiune prin releu deja stabilită conexiunea directă WebRTC înainte de a fi folosită în locul ei. Măriți valoarea pentru a acorda mai mult timp unei conexiuni directe lente; micșorați-o pentru a trece mai repede la releu în rețelele în care o conexiune directă nu este posibilă. Lăsați gol pentru valoarea implicită de 2.5 secunde."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -730,7 +730,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("preset-password-in-use-tip", "Установленный пароль сейчас используется."),
|
||||
("Enable privacy mode", "Использовать режим конфиденциальности"),
|
||||
("allow-remote-toolbar-docking-any-edge", "Разрешать прикрепление удалённой панели инструментов к любому краю окна"),
|
||||
("API Token", "Токен API"),
|
||||
("API Token", "API-токен"),
|
||||
("Deploy", "Развернуть"),
|
||||
("Custom ID (optional)", "Пользовательский ID (необязательно)"),
|
||||
("server_requires_deployment_tip", "Сервер требует явного развёртывания этого устройства. Развернуть сейчас?"),
|
||||
@@ -748,23 +748,23 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Show on the minimized toolbar", "Показывать на свёрнутой панели инструментов"),
|
||||
("All monitors", "Все мониторы"),
|
||||
("#{} monitor", "Монитор {}"),
|
||||
("conn-e2ee-unavailable-tip", "Невозможно проверить сквозное шифрование.\nУдалённое устройство, возможно, ещё настраивается. Повторите попытку позже.\nЕсли это повторяется, сервер может быть ненадёжным.\nВсё равно продолжить?"),
|
||||
("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"),
|
||||
("whitelist_cidr_tip", "Поддерживается нотация CIDR, например: 192.168.1.0/24"),
|
||||
("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", "Текст или изображения, скопированные в одном удалённом сеансе, также отправляются в буфер обмена других подключённых сеансов."),
|
||||
("terminal-clipboard-write-tip", "Приложение в терминале хочет скопировать текст в буфер обмена этого устройства. Если соответствующее разрешение предоставлено, оно применяется к приложениям в терминале во всех соединениях, пока вы не отключите его в настройках. Ручные копирование и вставка не затрагиваются."),
|
||||
("Allow terminal apps to copy to clipboard", "Разрешить приложениям в терминале копирование в буфер обмена"),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Включить"),
|
||||
("Reuse one connection for port forwarding", "Использовать одно подключение для перенаправления портов"),
|
||||
("port-forward-mux-tip", "Передавать все соединения одного перенаправления портов через одно подключение к удалённому устройству вместо повторного подключения и входа для каждого из них."),
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Запрос на демонстрацию экрана на удалённом устройстве завершился, не будучи выполненным"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk не смог получить пригодный экран от XDG Desktop Portal, библиотека PipeWire может быть слишком старой"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk не удалось загрузить компонент GStreamer, необходимый для захвата экрана ({})"),
|
||||
("Relay fallback delay in seconds", "Задержка перед переходом на ретранслятор в секундах"),
|
||||
("relay-fallback-delay-tip", "Сколько времени уже установленное соединение через ретранслятор ждёт прямое соединение WebRTC, прежде чем будет использовано вместо него. Увеличьте, чтобы дать медленному прямому соединению больше времени; уменьшите, чтобы быстрее переходить на ретранслятор в сетях, где прямое соединение невозможно. Оставьте пустым для значения по умолчанию 2.5 секунды."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "Чтобы начать голосовой вызов, включите \"Захват аудио\" на странице \"Демонстрация экрана\" настроек.")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Sa rechesta de cumpartzidura de sa schermada in su dispositivu remotu est acabada chene si cumpletare"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk no at pòdidu otènnere una schermada impreabile dae XDG Desktop Portal, sa libreria PipeWire podet èssere tropu betza"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk no at pòdidu carrigare unu cumponente de GStreamer netzessàriu pro registrare sa schermada ({})"),
|
||||
("Relay fallback delay in seconds", "Tardu prima de impreare su relè in segundos"),
|
||||
("relay-fallback-delay-tip", "Cantu tempus una connessione de relè giai istabilida abetat sa connessione direta WebRTC prima de èssere impreada in su postu suo. Aumenta pro dare prus tempus a una connessione direta lenta; diminuì pro colare prima a su relè in sas retes in ue non si podet fàghere una connessione direta. Lassa bòidu pro su valore predefinidu de 2.5 segundos."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Žiadosť o zdieľanie obrazovky na vzdialenom zariadení sa skončila bez dokončenia"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nezískal z XDG Desktop Portal použiteľnú obrazovku, knižnica PipeWire môže byť príliš stará"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nedokázal načítať komponent GStreamera potrebný na zachytenie obrazovky ({})"),
|
||||
("Relay fallback delay in seconds", "Oneskorenie pred prepnutím na prenos v sekundách"),
|
||||
("relay-fallback-delay-tip", "Ako dlho už nadviazané spojenie cez prenos čaká na priame spojenie WebRTC, kým sa použije namiesto neho. Zvýšte, aby pomalé priame spojenie malo viac času; znížte, aby sa v sieťach, kde priame spojenie nie je možné, skôr prešlo na prenos. Nechajte prázdne pre predvolenú hodnotu 2.5 sekundy."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -778,8 +778,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("The screen sharing request ended without completing on the remote device", "Zahteva za skupno rabo zaslona na oddaljeni napravi se je končala, ne da bi bila dokončana"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk od XDG Desktop Portala ni dobil uporabnega zaslona, knjižnica PipeWire je morda prestara"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ni mogel naložiti komponente GStreamer, potrebne za zajem zaslona ({})"),
|
||||
("Relay fallback delay in seconds", "Zakasnitev pred preklopom na posrednika v sekundah"),
|
||||
("relay-fallback-delay-tip", "Kako dolgo že vzpostavljena posredniška povezava čaka na neposredno povezavo WebRTC, preden se uporabi namesto nje. Povečajte, da počasni neposredni povezavi date več časa; zmanjšajte, da v omrežjih, kjer neposredna povezava ni mogoča, hitreje preklopite na posrednika. Pustite prazno za privzeto vrednost 2.5 sekunde."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user