mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-16 17:31:04 +03:00
Compare commits
14 Commits
fix-16226-
...
webrtc-ans
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
731df99b0c | ||
|
|
092a961b62 | ||
|
|
4f56d6957a | ||
|
|
f312b57909 | ||
|
|
f9c2f16857 | ||
|
|
8154c026a4 | ||
|
|
28269af9c7 | ||
|
|
20e25e743d | ||
|
|
851d2df88c | ||
|
|
be757de462 | ||
|
|
a437eb9bc6 | ||
|
|
39d4f1854b | ||
|
|
bd028ad350 | ||
|
|
b48fe17b52 |
45
Cargo.lock
generated
45
Cargo.lock
generated
@@ -1717,7 +1717,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#69ad2578adc9200093fc81cdfbdad63dbc4274f9"
|
||||
source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#96d4da121b7d949677ac5b6887413a9185fd7f39"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"cidre",
|
||||
@@ -4121,8 +4121,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "interceptor"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac0781c825d602095113772e389ef0607afcb869ae0e68a590d8e0799cdcef8"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -7008,8 +7007,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rtcp"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9689528bf3a9eb311fd938d05516dd546412f9ce4fffc8acfc1db27cc3dbf72"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"thiserror 1.0.61",
|
||||
@@ -7019,8 +7017,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rtp"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c54733451a67d76caf9caa07a7a2cec6871ea9dda92a7847f98063d459200f4b"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
@@ -7465,8 +7462,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sdp"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd277015eada44a0bb810a4b84d3bf6e810573fa62fb442f457edf6a1087a69"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"substring",
|
||||
@@ -8038,8 +8034,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "stun"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dbc2bab375524093c143dc362a03fb6a1fb79e938391cdb21665688f88a088a"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"crc",
|
||||
@@ -8921,8 +8916,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "turn"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f5aea1116456e1da71c45586b87c72e3b43164fbf435eb93ff6aa475416a9a4"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -9585,8 +9579,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24bab7195998d605c862772f90a452ba655b90a2f463c850ac032038890e367a"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9629,8 +9622,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-data"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e97b932854da633a767eff0cc805425a2222fc6481e96f463e57b015d949d1d"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"log",
|
||||
@@ -9644,8 +9636,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-dtls"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ccbe4d9049390ab52695c3646c1395c877e16c15fb05d3bda8eee0c7351711c"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -9681,8 +9672,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-ice"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb51bde0d790f109a15bfe4d04f1b56fb51d567da231643cb3f21bb74d678997"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9706,8 +9696,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-mdns"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "979cc85259c53b7b620803509d10d35e2546fa505d228850cbe3f08765ea6ea8"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"log",
|
||||
"socket2 0.5.10",
|
||||
@@ -9719,8 +9708,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-media"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80041211deccda758a3e19aa93d6b10bc1d37c9183b519054b40a83691d13810"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -9732,7 +9720,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-sctp"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=db3b07a9dd8f195916c89c2e62a8911402b11d27#db3b07a9dd8f195916c89c2e62a8911402b11d27"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9749,8 +9737,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-srtp"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01e773f79b09b057ffbda6b03fe7b43403b012a240cf8d05d630674c3723b5bb"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
@@ -9772,7 +9759,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-util"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=db3b07a9dd8f195916c89c2e62a8911402b11d27#db3b07a9dd8f195916c89c2e62a8911402b11d27"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags 1.3.2",
|
||||
|
||||
@@ -231,8 +231,11 @@ 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-util = { git = "https://github.com/rustdesk-org/webrtc", rev = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
|
||||
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
|
||||
# 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" }
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -19,7 +20,13 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
ACT_REQUEST_MEDIA_PROJECTION -> {
|
||||
val mediaProjectionManager =
|
||||
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val intent = mediaProjectionManager.createScreenCaptureIntent()
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
mediaProjectionManager.createScreenCaptureIntent(
|
||||
MediaProjectionConfig.createConfigForDefaultDisplay()
|
||||
)
|
||||
} else {
|
||||
mediaProjectionManager.createScreenCaptureIntent()
|
||||
}
|
||||
startActivityForResult(intent, REQ_REQUEST_MEDIA_PROJECTION)
|
||||
}
|
||||
else -> finish()
|
||||
|
||||
Submodule libs/hbb_common updated: 29cf7cbe4d...daf84c2df3
@@ -41,6 +41,13 @@ impl<T> Drop for ComPtr<T> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum FrameState {
|
||||
Idle,
|
||||
Acquired,
|
||||
Mapped,
|
||||
}
|
||||
|
||||
pub struct Capturer {
|
||||
device: ComPtr<ID3D11Device>,
|
||||
display: Display,
|
||||
@@ -58,6 +65,7 @@ pub struct Capturer {
|
||||
output_texture: bool,
|
||||
adapter_desc1: DXGI_ADAPTER_DESC1,
|
||||
rotate: Rotate,
|
||||
frame_state: FrameState,
|
||||
}
|
||||
|
||||
impl Capturer {
|
||||
@@ -174,6 +182,7 @@ impl Capturer {
|
||||
output_texture: false,
|
||||
adapter_desc1,
|
||||
rotate,
|
||||
frame_state: FrameState::Idle,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -335,6 +344,7 @@ 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 {
|
||||
@@ -345,9 +355,11 @@ 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))
|
||||
}
|
||||
@@ -424,7 +436,7 @@ impl Capturer {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.unmap();
|
||||
self.release_frame()?;
|
||||
let r = self.load_frame(timeout)?;
|
||||
let rotate = match self.display.rotation() {
|
||||
DXGI_MODE_ROTATION_IDENTITY | DXGI_MODE_ROTATION_UNSPECIFIED => kRotate0,
|
||||
@@ -472,12 +484,13 @@ impl Capturer {
|
||||
if self.duplication.0.is_null() {
|
||||
return Err(std::io::ErrorKind::AddrNotAvailable.into());
|
||||
}
|
||||
(*self.duplication.0).ReleaseFrame();
|
||||
self.release_frame()?;
|
||||
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 {
|
||||
@@ -574,16 +587,42 @@ impl Capturer {
|
||||
}
|
||||
}
|
||||
|
||||
fn unmap(&self) {
|
||||
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.
|
||||
unsafe {
|
||||
(*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::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);
|
||||
}
|
||||
}
|
||||
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(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,8 +638,8 @@ impl Capturer {
|
||||
|
||||
impl Drop for Capturer {
|
||||
fn drop(&mut self) {
|
||||
if !self.duplication.is_null() {
|
||||
self.unmap();
|
||||
if let Err(err) = self.release_frame() {
|
||||
eprintln!("DXGI frame cleanup failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ 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, get_rs_pk, is_keyboard_mode_supported,
|
||||
create_symmetric_key_msg, decode_id_pk, decode_id_pk_dtls, dtls_fingerprint_bound, get_rs_pk,
|
||||
is_keyboard_mode_supported,
|
||||
kcp_stream::KcpStream,
|
||||
secure_tcp,
|
||||
ui_interface::{get_builtin_option, resolve_avatar_url, use_texture_render},
|
||||
@@ -95,7 +96,10 @@ 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;
|
||||
@@ -1668,7 +1672,7 @@ impl Client {
|
||||
let actual_fp = conn.dtls_fingerprint(false).await.ok_or_else(
|
||||
|| anyhow!("WebRTC DTLS fingerprint unavailable"),
|
||||
)?;
|
||||
if signed_fp.is_empty() || signed_fp != actual_fp {
|
||||
if !dtls_fingerprint_bound(&signed_fp, &actual_fp) {
|
||||
bail!("WebRTC DTLS fingerprint not bound to peer identity (possible MITM)");
|
||||
}
|
||||
}
|
||||
@@ -2083,6 +2087,8 @@ pub struct AudioHandler {
|
||||
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"))]
|
||||
@@ -2390,22 +2396,53 @@ impl AudioHandler {
|
||||
|
||||
/// 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 = false;
|
||||
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 = self.start_audio(f);
|
||||
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);
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -2493,6 +2530,9 @@ 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);
|
||||
@@ -4065,7 +4105,11 @@ pub fn start_audio_thread() -> MediaSender {
|
||||
std::thread::spawn(move || {
|
||||
let mut audio_handler = AudioHandler::default();
|
||||
loop {
|
||||
if let Ok(data) = audio_receiver.recv() {
|
||||
#[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 {
|
||||
match data {
|
||||
MediaData::AudioFrame(af) => {
|
||||
audio_handler.handle_frame(*af);
|
||||
|
||||
184
src/client/audio_playback_recovery.rs
Normal file
184
src/client/audio_playback_recovery.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/client/audio_playback_startup.rs
Normal file
63
src/client/audio_playback_startup.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
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,113 +0,0 @@
|
||||
use super::{create_audio_resampler, AudioDecoder, AudioFrame, AudioHandler, Stereo};
|
||||
use cpal::traits::StreamTrait;
|
||||
use hbb_common::anyhow::anyhow;
|
||||
use magnum_opus::{Application::LowDelay, Encoder};
|
||||
use ringbuf::{ring_buffer::RbBase, 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;
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_format_change_discards_old_playback_state() {
|
||||
let (mut handler, dropped) = active_handler(INPUT_RATE);
|
||||
handler
|
||||
.audio_buffer
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_slice(&[SAMPLE_VALUE; CHANNELS as usize]);
|
||||
handler.audio_decoder = Some(decoder(OUTPUT_RATE));
|
||||
handler.sample_rate = (OUTPUT_RATE, OUTPUT_RATE);
|
||||
|
||||
handler.handle_audio_start_result(
|
||||
Err(anyhow!("Injected output stream startup failure")),
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
assert!(handler.audio_stream.is_none());
|
||||
assert!(handler.audio_resampler.is_none());
|
||||
assert!(handler.audio_decoder.is_none());
|
||||
assert!(!handler.playback_status.ready.load(Ordering::Acquire));
|
||||
handler.handle_frame(audio_frame());
|
||||
assert_eq!(handler.audio_buffer.0.lock().unwrap().occupied_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_start_or_compatible_failure_preserves_audio_packet_duration() {
|
||||
for result in [
|
||||
Ok(()),
|
||||
Err(anyhow!("Injected compatible stream replacement failure")),
|
||||
] {
|
||||
let (mut handler, dropped) = active_handler(OUTPUT_RATE);
|
||||
|
||||
handler.handle_audio_start_result(result, true);
|
||||
handler.handle_frame(audio_frame());
|
||||
|
||||
assert!(!dropped.load(Ordering::SeqCst));
|
||||
assert_eq!(
|
||||
handler.audio_buffer.0.lock().unwrap().occupied_len(),
|
||||
OUTPUT_RATE as usize / PACKETS_PER_SECOND * CHANNELS as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
226
src/client/tests/audio_playback_recovery_tests.rs
Normal file
226
src/client/tests/audio_playback_recovery_tests.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
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());
|
||||
}
|
||||
128
src/client/tests/audio_state_tests.rs
Normal file
128
src/client/tests/audio_state_tests.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
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()
|
||||
}
|
||||
@@ -2166,6 +2166,13 @@ 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();
|
||||
@@ -3263,4 +3270,42 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,6 +780,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("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.", "")
|
||||
("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'.")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -780,6 +780,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("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.", "")
|
||||
("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"),
|
||||
@@ -764,7 +764,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("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 que aplicativos do terminal copiem para a área de transferência"),
|
||||
("Allow terminal apps to copy to clipboard", "Permitir cópia do terminal para a área de transferência"),
|
||||
("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."),
|
||||
@@ -779,7 +779,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("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", "Quanto tempo uma conexão de retransmissão já estabelecida espera pela conexão direta WebRTC antes de ser usada no lugar dela. Aumente para dar mais tempo a uma conexão direta lenta; diminua para recorrer mais cedo ao retransmissor em redes onde não é possível uma conexão direta. Deixe vazio para o valor padrão de 2.5 segundos."),
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
|
||||
("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\".")
|
||||
].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", "Передавать все соединения одного перенаправления портов через одно подключение к удалённому устройству вместо повторного подключения и входа для каждого из них."),
|
||||
@@ -780,6 +780,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("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.", "")
|
||||
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "Чтобы начать голосовой вызов, включите \"Захват аудио\" на странице \"Демонстрация экрана\" настроек.")
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -542,6 +542,26 @@ extern "C"
|
||||
SHAddToRecentDocs(SHARD_PATHW, path);
|
||||
}
|
||||
|
||||
// Hyper-V Enhanced Session names do not have the usual "rdp" prefix.
|
||||
static bool is_rdp_session_by_protocol(DWORD session_id)
|
||||
{
|
||||
LPSTR buffer = nullptr;
|
||||
DWORD bytes = 0;
|
||||
if (!WTSQuerySessionInformationA(
|
||||
WTS_CURRENT_SERVER_HANDLE, session_id, WTSClientProtocolType, &buffer, &bytes)) {
|
||||
flog("Failed to query protocol for session %lu: Windows error %lu\n",
|
||||
session_id, GetLastError());
|
||||
return false;
|
||||
}
|
||||
std::unique_ptr<char, decltype(&WTSFreeMemory)> protocol_info(buffer, WTSFreeMemory);
|
||||
if (!buffer || bytes < sizeof(USHORT)) {
|
||||
flog("Failed to query protocol for session %lu: Windows error %lu\n",
|
||||
session_id, static_cast<DWORD>(ERROR_INVALID_DATA));
|
||||
return false;
|
||||
}
|
||||
return *reinterpret_cast<const USHORT *>(buffer) == WTS_PROTOCOL_TYPE_RDP;
|
||||
}
|
||||
|
||||
DWORD get_current_session(BOOL include_rdp)
|
||||
{
|
||||
auto rdp_or_console = WTSGetActiveConsoleSessionId();
|
||||
@@ -573,6 +593,10 @@ extern "C"
|
||||
{
|
||||
rdp_or_console = info.SessionId;
|
||||
}
|
||||
else if (is_rdp_session_by_protocol(info.SessionId))
|
||||
{
|
||||
rdp_or_console = info.SessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
WTSFreeMemory(pInfos);
|
||||
@@ -666,6 +690,9 @@ extern "C"
|
||||
else if (include_rdp && !strnicmp(info.pWinStationName, ica, nica)) {
|
||||
sessionIds.push_back(std::wstring(L"ICA:") + std::to_wstring(info.SessionId));
|
||||
}
|
||||
else if (include_rdp && is_rdp_session_by_protocol(info.SessionId)) {
|
||||
sessionIds.push_back(std::wstring(L"RDP:") + std::to_wstring(info.SessionId));
|
||||
}
|
||||
}
|
||||
}
|
||||
WTSFreeMemory(pInfos);
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::{
|
||||
hash::BuildHasher,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
Arc, RwLock,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
@@ -63,6 +63,36 @@ const MAX_PENDING_REMOTE_ICE: usize = 64;
|
||||
/// Queued candidates remembered so the controller's re-send is skipped instead of taking a slot
|
||||
/// of its own. Far more than an honest peer gathers, at eight bytes each.
|
||||
const ICE_DEDUP_WINDOW: usize = 256;
|
||||
/// Answerers between an offer and an open data channel. An offer arrives before any password or
|
||||
/// accept prompt, and each one builds a peer connection that binds a socket per interface and
|
||||
/// runs ICE for up to `CONNECT_TIMEOUT`, where a forged TCP punch costs one connect. Past this
|
||||
/// many the offer is declined, and the controller carries on over punch and relay as it does
|
||||
/// for a peer without WebRTC. A guard against pathological setup concurrency, above what
|
||||
/// legitimate controllers reach at once in the seconds ICE takes; once the channel is open the
|
||||
/// connection is one like any other, and the connection layer bounds unauthenticated
|
||||
/// connections in number and in time for every transport alike.
|
||||
const MAX_WEBRTC_ANSWERERS: usize = 16;
|
||||
static WEBRTC_ANSWERERS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// One of the `MAX_WEBRTC_ANSWERERS` slots, given back on drop.
|
||||
struct AnswererSlot;
|
||||
|
||||
impl AnswererSlot {
|
||||
fn take() -> Option<Self> {
|
||||
WEBRTC_ANSWERERS
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
|
||||
(n < MAX_WEBRTC_ANSWERERS).then(|| n + 1)
|
||||
})
|
||||
.ok()
|
||||
.map(|_| Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AnswererSlot {
|
||||
fn drop(&mut self) {
|
||||
WEBRTC_ANSWERERS.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
// The rendezvous ICE route is reachable without a prior punch and the peer decides how many
|
||||
// candidates it sends, so these sites would let someone else set how much this machine writes to
|
||||
// its log file. One line a minute each, carrying the suppressed count.
|
||||
@@ -749,6 +779,15 @@ impl RendezvousMediator {
|
||||
peer_addr: SocketAddr,
|
||||
meta: ConnectionMeta,
|
||||
) -> ResultType<String> {
|
||||
let Some(slot) = AnswererSlot::take() else {
|
||||
hbb_common::throttled_log!(
|
||||
ICE_LOG_INTERVAL,
|
||||
warn,
|
||||
"declined a WebRTC offer: {} answerers already in flight",
|
||||
MAX_WEBRTC_ANSWERERS
|
||||
);
|
||||
return Ok(String::new());
|
||||
};
|
||||
let mut stream =
|
||||
WebRTCStream::new(&ph.webrtc_sdp_offer, relay_only_ice, CONNECT_TIMEOUT).await?;
|
||||
let answer = stream.local_endpoint().to_owned();
|
||||
@@ -849,6 +888,11 @@ impl RendezvousMediator {
|
||||
let session_key_for_cleanup = session_key.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = stream.wait_connected(CONNECT_TIMEOUT).await;
|
||||
// The slot covers the setup an unauthenticated offer makes this machine pay for, ICE,
|
||||
// DTLS and SCTP, and that wait is bounded by CONNECT_TIMEOUT. Release it here, before
|
||||
// the cleanup and the close below, so their duration is never added to a slot's life;
|
||||
// with the channel open the session is a connection like any other.
|
||||
drop(slot);
|
||||
// Only evict our own route. The key is the offer's DTLS fingerprint, identical across
|
||||
// the controller's punch retries, so a retry that built a fresh answerer has already
|
||||
// replaced this entry — removing it blindly would delete the live session's sender and
|
||||
@@ -1488,7 +1532,10 @@ impl Drop for CheckIfResendPk {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{mpsc, socket_client, tokio, IceRoute, ICE_DEDUP_WINDOW, MAX_PENDING_REMOTE_ICE};
|
||||
use super::{
|
||||
mpsc, socket_client, tokio, AnswererSlot, IceRoute, ICE_DEDUP_WINDOW,
|
||||
MAX_PENDING_REMOTE_ICE, MAX_WEBRTC_ANSWERERS,
|
||||
};
|
||||
use hbb_common::tcp::new_listener;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
@@ -1736,4 +1783,14 @@ mod tests {
|
||||
"must return when the grace runs out, not a backoff later"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_answerer_slots_cap_and_release() {
|
||||
let held: Vec<_> = (0..MAX_WEBRTC_ANSWERERS)
|
||||
.map(|_| AnswererSlot::take().unwrap())
|
||||
.collect();
|
||||
assert!(AnswererSlot::take().is_none());
|
||||
drop(held);
|
||||
assert!(AnswererSlot::take().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,15 @@ pub struct Server {
|
||||
pub type ServerPtr = Arc<RwLock<Server>>;
|
||||
pub type ServerPtrWeak = Weak<RwLock<Server>>;
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_for_test() -> ServerPtr {
|
||||
Arc::new(RwLock::new(Server {
|
||||
connections: HashMap::new(),
|
||||
services: HashMap::new(),
|
||||
id_count: 1000,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn new() -> ServerPtr {
|
||||
let mut server = Server {
|
||||
connections: HashMap::new(),
|
||||
@@ -204,7 +213,48 @@ pub async fn create_tcp_connection(
|
||||
meta: ConnectionMeta,
|
||||
) -> ResultType<()> {
|
||||
let mut stream = stream;
|
||||
// The address the connection layer keys on, whitelist and admission alike.
|
||||
let addr = hbb_common::try_into_v4(addr);
|
||||
let id = server.write().unwrap().get_new_id();
|
||||
// Admitted before the identity handshake, so a peer that stalls in it, or after it without
|
||||
// logging in, holds its place the whole time; an address over its share is turned away.
|
||||
let Some(unauthorized) = admit_unauthorized(id, addr.ip()) else {
|
||||
bail!("too many unauthenticated connections from {}", addr.ip());
|
||||
};
|
||||
tokio::select! {
|
||||
handshake = identity_handshake(&mut stream, secure) => handshake?,
|
||||
_ = unauthorized.evicted() => {
|
||||
bail!("evicted to make room for a newer unauthenticated connection");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::process::Command;
|
||||
if let Ok(task) = Command::new("/usr/bin/caffeinate")
|
||||
.arg("-u")
|
||||
.arg("-t 5")
|
||||
.spawn()
|
||||
{
|
||||
super::CHILD_PROCESS.lock().unwrap().push(task);
|
||||
}
|
||||
log::info!("wake up macos");
|
||||
}
|
||||
Connection::start(
|
||||
addr,
|
||||
stream,
|
||||
id,
|
||||
Arc::downgrade(&server),
|
||||
meta,
|
||||
unauthorized,
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Our signed identity goes out and, when `secure`, the controller's reply keys `stream`.
|
||||
/// Separate so it can be raced against the connection's eviction.
|
||||
async fn identity_handshake(stream: &mut Stream, secure: bool) -> ResultType<()> {
|
||||
let (sk, pk) = Config::get_key_pair();
|
||||
if secure && pk.len() == sign::PUBLICKEYBYTES && sk.len() == sign::SECRETKEYBYTES {
|
||||
let mut sk_ = [0u8; sign::SECRETKEYBYTES];
|
||||
@@ -267,19 +317,6 @@ pub async fn create_tcp_connection(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::process::Command;
|
||||
if let Ok(task) = Command::new("/usr/bin/caffeinate")
|
||||
.arg("-u")
|
||||
.arg("-t 5")
|
||||
.spawn()
|
||||
{
|
||||
super::CHILD_PROCESS.lock().unwrap().push(task);
|
||||
}
|
||||
log::info!("wake up macos");
|
||||
}
|
||||
Connection::start(addr, stream, id, Arc::downgrade(&server), meta).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -391,6 +391,15 @@ mod cpal_impl {
|
||||
if !audio_input.is_empty() {
|
||||
return get_audio_input(&audio_input);
|
||||
}
|
||||
// The pinned CPAL uses event-driven WASAPI loopback here. Windows versions
|
||||
// before Windows 10 1703 do not signal capture events, so system audio does
|
||||
// not work on Win7. #16095 kept the same CPAL revision and loopback path;
|
||||
// this limitation predates that PR.
|
||||
// Ordinary microphone input is supported on Win7 and uses the branch above.
|
||||
// #16095 added its callback-to-encoder wake dependency; see CapturePcmSender::wake
|
||||
// for the new scheduling risk, whose audible impact on Win7 is unmeasured.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/coreaudio/loopback-recording
|
||||
// https://learn.microsoft.com/en-us/windows/win32/coreaudio/capturesharedeventdriven
|
||||
let device = HOST
|
||||
.default_output_device()
|
||||
.with_context(|| "Failed to get default output device for loopback")?;
|
||||
|
||||
@@ -97,6 +97,7 @@ impl Drop for CaptureEncoderWorker {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
if let Some(handle) = self.handle.take() {
|
||||
// Owner-thread shutdown already waits via join(); see CapturePcmSender::wake for Win7.
|
||||
handle.thread().unpark();
|
||||
if let Err(error) = handle.join() {
|
||||
log::error!("Failed to join audio encoder thread: {error:?}");
|
||||
@@ -209,6 +210,16 @@ impl CapturePcmSender {
|
||||
|
||||
fn wake(&self) {
|
||||
if let Some(thread) = self.handoff.wake_thread.get() {
|
||||
// #16095 moved Opus encoding and message submission from the capture callback to a worker.
|
||||
// Previously, the callback did that work directly, with allocations and blocking locks.
|
||||
// On Win7 with Rust 1.75, if the worker is descheduled after publishing PARKED but before
|
||||
// NtWaitForKeyedEvent, unpark() waits in NtReleaseKeyedEvent until the worker enters that wait.
|
||||
// It does not wait for encoding; park_timeout() does not bound the callback's wait.
|
||||
// Delays can cause gaps or stall teardown; a Win7 microphone regression has not been measured.
|
||||
// System loopback already failed on Win7 before #16095 (see cpal_impl::get_device),
|
||||
// so the affected path is microphone/input-device capture, including outgoing voice calls.
|
||||
// Accept this risk to preserve Win7 input capture without a separate legacy notifier.
|
||||
// https://github.com/rust-lang/rust/blob/1.75.0/library/std/src/sys/windows/thread_parking.rs
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,15 +55,16 @@ use scrap::android::{call_main_service_key_event, call_main_service_pointer_inpu
|
||||
use scrap::camera;
|
||||
use serde_derive::Serialize;
|
||||
use serde_json::{json, value::Value};
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
net::Ipv6Addr,
|
||||
net::{IpAddr, Ipv6Addr},
|
||||
num::NonZeroI64,
|
||||
path::PathBuf,
|
||||
str::FromStr,
|
||||
sync::{atomic::AtomicI64, mpsc as std_mpsc},
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicI64, Ordering},
|
||||
mpsc as std_mpsc,
|
||||
},
|
||||
};
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use system_shutdown;
|
||||
@@ -79,7 +80,104 @@ const FAILURE_IDX_ID_WHITELIST: usize = 2;
|
||||
// throttles enumeration harder; shorter limits collateral on whitelisted neighbours.
|
||||
const ID_WHITELIST_FAILURE_DECAY_MINUTES: i32 = 10;
|
||||
|
||||
/// A connection not authorized within this long of starting is closed, however alive it
|
||||
/// keeps itself: a wrong password, a pending 2FA, an accept prompt or an admin-terminal
|
||||
/// credential prompt still unanswered. The controller reconnects on its own and the prompt
|
||||
/// comes back. A connection that says nothing at all goes at the 30 s idle timeout already.
|
||||
const LOGIN_GRACE: Duration = Duration::from_secs(180);
|
||||
/// Connections between accept and authorization, across every transport: the resource bound.
|
||||
/// At this many a further arrival is refused and the oldest is told to go, one at a time.
|
||||
const MAX_UNAUTHORIZED_CONNS: usize = 64;
|
||||
/// Of those, how many one address may hold at once: a quarter of the room. A fairness cap
|
||||
/// against the cheapest flood, one host with one address, not a security boundary: any pool
|
||||
/// of addresses passes it, and the bound above is what holds. Meaningful only while the
|
||||
/// address is the controller's own, which punch and relay messages carry today.
|
||||
const MAX_UNAUTHORIZED_CONNS_PER_ADDR: usize = 16;
|
||||
|
||||
/// A place among the unauthorized connections, taken before the identity handshake and given
|
||||
/// back on drop: at authorization, or when the connection ends first. The count of live
|
||||
/// guards is the bound; an evicted one is told to go and keeps its place until it has.
|
||||
pub struct UnauthorizedID {
|
||||
id: i32,
|
||||
shared: Arc<UnauthorizedShared>,
|
||||
}
|
||||
|
||||
struct UnauthorizedShared {
|
||||
evicted: AtomicBool,
|
||||
notify: hbb_common::tokio::sync::Notify,
|
||||
}
|
||||
|
||||
/// Admit a connection from `ip` among the unauthorized ones. `None` when that address already
|
||||
/// holds its share, or when the global limit is reached: then the oldest connection is told to
|
||||
/// go, unless one is on its way out already, and this one is refused rather than let in on a
|
||||
/// place that is still occupied. At most one connection is ever on its way out, so a burst of
|
||||
/// refused arrivals clears no more room than a single one. The controller retries on its own.
|
||||
pub fn admit_unauthorized(id: i32, ip: IpAddr) -> Option<UnauthorizedID> {
|
||||
let mut conns = UNAUTHORIZED_CONNS.lock().unwrap();
|
||||
if conns.iter().filter(|(_, held, _)| *held == ip).count() >= MAX_UNAUTHORIZED_CONNS_PER_ADDR {
|
||||
return None;
|
||||
}
|
||||
if conns.len() >= MAX_UNAUTHORIZED_CONNS {
|
||||
if let Some((_, _, oldest)) = conns.first() {
|
||||
if !oldest.evicted.swap(true, Ordering::AcqRel) {
|
||||
oldest.notify.notify_one();
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let shared = Arc::new(UnauthorizedShared {
|
||||
evicted: AtomicBool::new(false),
|
||||
notify: hbb_common::tokio::sync::Notify::new(),
|
||||
});
|
||||
conns.push((id, ip, shared.clone()));
|
||||
Some(UnauthorizedID { id, shared })
|
||||
}
|
||||
|
||||
impl UnauthorizedID {
|
||||
/// Whether this connection was told to go to make room for a newer one.
|
||||
pub fn is_evicted(&self) -> bool {
|
||||
self.shared.evicted.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Resolves once this connection is told to go; at once if it already was.
|
||||
pub async fn evicted(&self) {
|
||||
if self.is_evicted() {
|
||||
return;
|
||||
}
|
||||
self.shared.notify.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for UnauthorizedID {
|
||||
fn drop(&mut self) {
|
||||
UNAUTHORIZED_CONNS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(id, _, _)| *id != self.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves when the connection holding `unauthorized` is evicted; never once it has
|
||||
/// authorized and given its place back.
|
||||
async fn unauthorized_evicted(unauthorized: &Option<UnauthorizedID>) {
|
||||
match unauthorized {
|
||||
Some(u) => u.evicted().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves at the login deadline of a connection started at `started`; never once it has
|
||||
/// authorized.
|
||||
async fn login_deadline(authorized: bool, started: Instant) {
|
||||
if authorized {
|
||||
return std::future::pending().await;
|
||||
}
|
||||
time::sleep_until(started + LOGIN_GRACE).await
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
// Connections between accept and authorization, oldest first; see admit_unauthorized.
|
||||
static ref UNAUTHORIZED_CONNS: Mutex<Vec<(i32, IpAddr, Arc<UnauthorizedShared>)>> = Default::default();
|
||||
// [0] password, [1] 2FA, [2] ID whitelist.
|
||||
// Bucket 2 is separate so its rejections do not touch the password / 2FA budgets. It is
|
||||
// decayed in `check_id_whitelist` and cleared on auth, never on a bare id match.
|
||||
@@ -264,6 +362,8 @@ pub struct Connection {
|
||||
port_forward_address: String,
|
||||
tx_to_cm: mpsc::UnboundedSender<ipc::Data>,
|
||||
authorized: bool,
|
||||
// The place among the unauthorized connections; given back at authorization.
|
||||
unauthorized_id: Option<UnauthorizedID>,
|
||||
require_2fa: Option<totp_rs::TOTP>,
|
||||
awaiting_2fa: bool,
|
||||
keyboard: bool,
|
||||
@@ -299,12 +399,6 @@ pub struct Connection {
|
||||
tx_input: std_mpsc::Sender<MessageInput>,
|
||||
// handle input messages
|
||||
video_ack_required: bool,
|
||||
// Diagnostics only, gated by `RUSTDESK_QOS_VERBOSE`: how long the shared
|
||||
// write path blocked this second. The video send is inline in the message
|
||||
// loop, so a slow write also delays the delay probe and its reply.
|
||||
video_send_max_ms: u32,
|
||||
video_send_sum_ms: u32,
|
||||
video_send_count: u32,
|
||||
server_audit_conn: String,
|
||||
server_audit_file: String,
|
||||
controlled_context: Option<ControlledContext>,
|
||||
@@ -425,6 +519,7 @@ impl Connection {
|
||||
id: i32,
|
||||
server: super::ServerPtrWeak,
|
||||
meta: super::ConnectionMeta,
|
||||
unauthorized: UnauthorizedID,
|
||||
) {
|
||||
let super::ConnectionMeta {
|
||||
control_permissions,
|
||||
@@ -483,6 +578,7 @@ impl Connection {
|
||||
port_forward_address: "".to_owned(),
|
||||
tx_to_cm,
|
||||
authorized: false,
|
||||
unauthorized_id: Some(unauthorized),
|
||||
keyboard: Self::permission(keys::OPTION_ENABLE_KEYBOARD, &control_permissions),
|
||||
clipboard: Self::permission(keys::OPTION_ENABLE_CLIPBOARD, &control_permissions),
|
||||
audio: Self::permission(keys::OPTION_ENABLE_AUDIO, &control_permissions),
|
||||
@@ -510,9 +606,6 @@ impl Connection {
|
||||
show_my_cursor: false,
|
||||
tx_input,
|
||||
video_ack_required: false,
|
||||
video_send_max_ms: 0,
|
||||
video_send_sum_ms: 0,
|
||||
video_send_count: 0,
|
||||
server_audit_conn: "".to_owned(),
|
||||
server_audit_file: "".to_owned(),
|
||||
controlled_context,
|
||||
@@ -598,6 +691,7 @@ impl Connection {
|
||||
let mut test_delay_timer =
|
||||
crate::rustdesk_interval(time::interval_at(Instant::now(), TEST_DELAY_TIMEOUT));
|
||||
let mut last_recv_time = Instant::now();
|
||||
let started = Instant::now();
|
||||
|
||||
// The connection type is not known until the login request arrives;
|
||||
// `on_message` picks the type-specific timeout then.
|
||||
@@ -633,6 +727,17 @@ impl Connection {
|
||||
tokio::select! {
|
||||
// biased; // video has higher priority // causing test_delay_timer failed while transferring big file
|
||||
|
||||
// Both end an unauthorized connection at once, not on the next timer tick:
|
||||
// told to go to make room, or past the grace for its authorization. Neither
|
||||
// fires once the connection has authorized.
|
||||
_ = unauthorized_evicted(&conn.unauthorized_id) => {
|
||||
conn.on_close("Timeout", true).await;
|
||||
break;
|
||||
}
|
||||
_ = login_deadline(conn.authorized, started) => {
|
||||
conn.on_close("Timeout", true).await;
|
||||
break;
|
||||
}
|
||||
Some(data) = rx_from_cm.recv() => {
|
||||
match data {
|
||||
ipc::Data::Authorize => {
|
||||
@@ -959,17 +1064,10 @@ impl Connection {
|
||||
video_service::notify_video_frame_fetched(vf.display as usize, id, Some(instant.into()));
|
||||
}
|
||||
}
|
||||
let send_begin = video_service::qos_diag_verbose().then(Instant::now);
|
||||
if let Err(err) = conn.stream.send(&value as &Message).await {
|
||||
conn.on_close(&err.to_string(), false).await;
|
||||
break;
|
||||
}
|
||||
if let Some(begin) = send_begin {
|
||||
let blocked = begin.elapsed().as_millis() as u32;
|
||||
conn.video_send_max_ms = conn.video_send_max_ms.max(blocked);
|
||||
conn.video_send_sum_ms = conn.video_send_sum_ms.saturating_add(blocked);
|
||||
conn.video_send_count += 1;
|
||||
}
|
||||
},
|
||||
Some((instant, value)) = rx.recv() => {
|
||||
let latency = instant.elapsed().as_millis() as i64;
|
||||
@@ -1056,21 +1154,6 @@ impl Connection {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if video_service::qos_diag_verbose() && conn.video_send_count > 0 {
|
||||
// Joined with `qos_trace` on `t`: a probe that waits behind a
|
||||
// blocked write is not a slow network.
|
||||
log::debug!(
|
||||
"qos_send t={} id={id} frames={} send_max={} send_sum={} queued={}",
|
||||
hbb_common::get_time(),
|
||||
conn.video_send_count,
|
||||
conn.video_send_max_ms,
|
||||
conn.video_send_sum_ms,
|
||||
rx_video.len()
|
||||
);
|
||||
conn.video_send_max_ms = 0;
|
||||
conn.video_send_sum_ms = 0;
|
||||
conn.video_send_count = 0;
|
||||
}
|
||||
conn.file_remove_log_control.on_timer().drain(..).map(|x| conn.send_to_cm(x)).count();
|
||||
#[cfg(feature = "hwcodec")]
|
||||
conn.update_supported_encoding();
|
||||
@@ -1792,6 +1875,7 @@ impl Connection {
|
||||
return false;
|
||||
}
|
||||
self.authorized = true;
|
||||
self.unauthorized_id = None;
|
||||
// Releases the budget `check_id_whitelist` charges against this address: only a peer
|
||||
// that got this far proved more than a self-reported id.
|
||||
self.clear_id_whitelist_failures();
|
||||
@@ -6994,6 +7078,167 @@ mod test {
|
||||
#[allow(unused)]
|
||||
use super::*;
|
||||
|
||||
// The registry is process-global and the harness runs tests in parallel threads, so every
|
||||
// test that admits connections holds this first; a poisoned lock is still a lock.
|
||||
static UNAUTHORIZED_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn unauthorized_count() -> usize {
|
||||
UNAUTHORIZED_CONNS.lock().unwrap().len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unauthorized_admission_is_per_address() {
|
||||
let _serial = UNAUTHORIZED_TESTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let a: IpAddr = "203.0.113.1".parse().unwrap();
|
||||
let b: IpAddr = "203.0.113.2".parse().unwrap();
|
||||
let held: Vec<_> = (0..MAX_UNAUTHORIZED_CONNS_PER_ADDR as i32)
|
||||
.map(|i| admit_unauthorized(1_000_000 + i, a).unwrap())
|
||||
.collect();
|
||||
assert!(admit_unauthorized(1_000_100, a).is_none());
|
||||
assert!(
|
||||
held.iter().all(|u| !u.is_evicted()),
|
||||
"a refusal evicts nobody"
|
||||
);
|
||||
let other = admit_unauthorized(1_000_101, b).unwrap();
|
||||
assert!(!other.is_evicted());
|
||||
drop(held);
|
||||
assert!(admit_unauthorized(1_000_102, a).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unauthorized_full_tells_the_oldest_to_go_and_frees_its_place_only_when_it_has() {
|
||||
let _serial = UNAUTHORIZED_TESTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut held: Vec<_> = (0..MAX_UNAUTHORIZED_CONNS as i32)
|
||||
.map(|i| {
|
||||
let ip: IpAddr = format!("198.51.100.{}", i + 1).parse().unwrap();
|
||||
admit_unauthorized(2_000_000 + i, ip).unwrap()
|
||||
})
|
||||
.collect();
|
||||
// At the limit the newcomer is refused, the oldest is told to go, and the count does
|
||||
// not move: the place is still occupied.
|
||||
assert!(admit_unauthorized(2_000_999, "198.51.100.250".parse().unwrap()).is_none());
|
||||
assert!(held[0].is_evicted());
|
||||
assert!(held[1..].iter().all(|u| !u.is_evicted()));
|
||||
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS);
|
||||
hbb_common::timeout(1000, held[0].evicted()).await.unwrap();
|
||||
// A further arrival while that one is still on its way out tells nobody else to go: a
|
||||
// burst of refused arrivals clears no more room than a single one.
|
||||
assert!(admit_unauthorized(2_001_000, "198.51.100.251".parse().unwrap()).is_none());
|
||||
assert!(held[1..].iter().all(|u| !u.is_evicted()));
|
||||
// Only once an evicted connection has gone is there a place for a newcomer.
|
||||
drop(held.remove(0));
|
||||
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS - 1);
|
||||
let newcomer = admit_unauthorized(2_001_001, "198.51.100.252".parse().unwrap()).unwrap();
|
||||
assert!(!newcomer.is_evicted());
|
||||
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS);
|
||||
// Full again, the next arrival tells the connection now oldest to go.
|
||||
assert!(admit_unauthorized(2_001_002, "198.51.100.253".parse().unwrap()).is_none());
|
||||
assert!(held[0].is_evicted());
|
||||
assert!(held[1..].iter().all(|u| !u.is_evicted()));
|
||||
assert!(!newcomer.is_evicted());
|
||||
}
|
||||
|
||||
// The per-address share holds at the limit too: an address can turn out at most that many
|
||||
// connections, one per place it then takes, and is refused before any eviction from then on.
|
||||
#[test]
|
||||
fn test_unauthorized_full_one_address_turns_out_at_most_its_share() {
|
||||
let _serial = UNAUTHORIZED_TESTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut held: Vec<_> = (0..MAX_UNAUTHORIZED_CONNS as i32)
|
||||
.map(|i| {
|
||||
let ip: IpAddr = format!("198.51.100.{}", i + 1).parse().unwrap();
|
||||
admit_unauthorized(3_000_000 + i, ip).unwrap()
|
||||
})
|
||||
.collect();
|
||||
let flooder: IpAddr = "203.0.113.9".parse().unwrap();
|
||||
let mut taken = Vec::new();
|
||||
for i in 0..MAX_UNAUTHORIZED_CONNS_PER_ADDR as i32 {
|
||||
assert!(admit_unauthorized(3_001_000 + i, flooder).is_none());
|
||||
assert!(held[0].is_evicted());
|
||||
drop(held.remove(0));
|
||||
taken.push(admit_unauthorized(3_002_000 + i, flooder).unwrap());
|
||||
}
|
||||
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS);
|
||||
assert!(admit_unauthorized(3_003_000, flooder).is_none());
|
||||
assert!(held.iter().all(|u| !u.is_evicted()));
|
||||
assert!(taken.iter().all(|u| !u.is_evicted()));
|
||||
}
|
||||
|
||||
/// A loopback TCP connection as create_tcp_connection sees it, and the controller's end,
|
||||
/// which never speaks: the connection stalls in the identity handshake.
|
||||
async fn stalled_incoming() -> (Stream, Stream, SocketAddr) {
|
||||
let listener = hbb_common::tcp::new_listener("127.0.0.1:0", false)
|
||||
.await
|
||||
.unwrap();
|
||||
let host = listener.local_addr().unwrap().to_string();
|
||||
let controller = hbb_common::socket_client::connect_tcp(host, 3000)
|
||||
.await
|
||||
.unwrap();
|
||||
let (accepted, addr) = listener.accept().await.unwrap();
|
||||
let served = Stream::Tcp(hbb_common::tcp::FramedStream::from(accepted, addr));
|
||||
(served, controller, addr)
|
||||
}
|
||||
|
||||
// Live connections, not bookkeeping: with the limit reached by connections stalled in the
|
||||
// handshake, one more arrival is refused and the oldest handshake is ended at once, not on
|
||||
// a timer tick, so the live count never exceeds the limit and a place opens only then.
|
||||
#[tokio::test]
|
||||
async fn test_unauthorized_limit_bounds_live_handshakes() {
|
||||
let _serial = UNAUTHORIZED_TESTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let server = crate::server::new_for_test();
|
||||
let mut controllers = Vec::new();
|
||||
let mut handshakes = Vec::new();
|
||||
for i in 0..MAX_UNAUTHORIZED_CONNS {
|
||||
let (served, controller, _) = stalled_incoming().await;
|
||||
controllers.push(controller);
|
||||
// Each from an address of its own, so only the global limit is in play.
|
||||
let addr: SocketAddr = format!("192.0.2.{}:1", i + 1).parse().unwrap();
|
||||
let server = server.clone();
|
||||
handshakes.push(tokio::spawn(async move {
|
||||
crate::server::create_tcp_connection(server, served, addr, true, Default::default())
|
||||
.await
|
||||
}));
|
||||
}
|
||||
for _ in 0..200 {
|
||||
if unauthorized_count() == MAX_UNAUTHORIZED_CONNS {
|
||||
break;
|
||||
}
|
||||
hbb_common::sleep(0.02).await;
|
||||
}
|
||||
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS);
|
||||
assert!(handshakes.iter().all(|h| !h.is_finished()));
|
||||
|
||||
let (served, _controller, _) = stalled_incoming().await;
|
||||
let addr: SocketAddr = "192.0.2.200:1".parse().unwrap();
|
||||
let refused = crate::server::create_tcp_connection(
|
||||
server.clone(),
|
||||
served,
|
||||
addr,
|
||||
true,
|
||||
Default::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(refused.is_err());
|
||||
let ended = hbb_common::timeout(2000, handshakes.remove(0)).await;
|
||||
assert!(
|
||||
matches!(ended, Ok(Ok(Err(_)))),
|
||||
"the oldest handshake ends on eviction"
|
||||
);
|
||||
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS - 1);
|
||||
assert!(
|
||||
handshakes.iter().all(|h| !h.is_finished()),
|
||||
"only the oldest was ended"
|
||||
);
|
||||
|
||||
drop(controllers);
|
||||
for h in handshakes {
|
||||
assert!(
|
||||
matches!(hbb_common::timeout(3000, h).await, Ok(Ok(Err(_)))),
|
||||
"a stalled handshake ends when its controller goes"
|
||||
);
|
||||
}
|
||||
assert_eq!(unauthorized_count(), 0, "no handshake outlives the test");
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[test]
|
||||
|
||||
@@ -625,7 +625,7 @@ impl VideoQoS {
|
||||
.clamp(MIN_AUTO_FPS.min(user_cap), user_cap)
|
||||
.min(current);
|
||||
user.delay.fps = Some(fps);
|
||||
log::debug!(
|
||||
log::trace!(
|
||||
"qos_trace t={} id={id} timeout={elapsed} fps={fps}",
|
||||
hbb_common::get_time()
|
||||
);
|
||||
|
||||
@@ -52,6 +52,8 @@ use scrap::{
|
||||
CodecFormat, Display, EncodeInput, TraitCapturer, TraitPixelBuffer,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use std::io::ErrorKind::ConnectionReset;
|
||||
#[cfg(windows)]
|
||||
use std::sync::Once;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
@@ -62,6 +64,59 @@ use std::{
|
||||
|
||||
pub const OPTION_REFRESH: &'static str = "refresh";
|
||||
|
||||
#[cfg(windows)]
|
||||
const DXGI_RECOVERY_LIMIT: usize = 3;
|
||||
#[cfg(windows)]
|
||||
const DXGI_RECOVERY_WINDOW: Duration = Duration::from_secs(10);
|
||||
#[cfg(windows)]
|
||||
const DXGI_RECOVERY_FRAME_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
#[cfg(windows)]
|
||||
struct DxgiRecoveryState {
|
||||
attempts: usize,
|
||||
window_started: Option<Instant>,
|
||||
restart_pending: bool,
|
||||
fallback_pending: bool,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl DxgiRecoveryState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
attempts: 0,
|
||||
window_started: None,
|
||||
restart_pending: false,
|
||||
fallback_pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_attempt(&mut self) -> Option<usize> {
|
||||
if self
|
||||
.window_started
|
||||
.map(|started| started.elapsed() > DXGI_RECOVERY_WINDOW)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
self.attempts = 0;
|
||||
self.window_started = Some(Instant::now());
|
||||
}
|
||||
if self.attempts >= DXGI_RECOVERY_LIMIT {
|
||||
self.fallback_pending = true;
|
||||
return None;
|
||||
}
|
||||
self.attempts += 1;
|
||||
self.restart_pending = true;
|
||||
Some(self.attempts)
|
||||
}
|
||||
|
||||
fn take_restart_pending(&mut self) -> bool {
|
||||
std::mem::take(&mut self.restart_pending)
|
||||
}
|
||||
|
||||
fn take_fallback_pending(&mut self) -> bool {
|
||||
std::mem::take(&mut self.fallback_pending)
|
||||
}
|
||||
}
|
||||
|
||||
type FrameFetchedNotifierSender = UnboundedSender<(i32, Option<Instant>)>;
|
||||
type FrameFetchedNotifierReceiver = Arc<TokioMutex<UnboundedReceiver<(i32, Option<Instant>)>>>;
|
||||
|
||||
@@ -220,6 +275,8 @@ pub struct VideoService {
|
||||
sp: GenericService,
|
||||
idx: usize,
|
||||
source: VideoSource,
|
||||
#[cfg(windows)]
|
||||
dxgi_recovery_state: Arc<Mutex<DxgiRecoveryState>>,
|
||||
}
|
||||
|
||||
impl Deref for VideoService {
|
||||
@@ -253,6 +310,8 @@ pub fn new(source: VideoSource, idx: usize) -> GenericService {
|
||||
sp: GenericService::new(get_service_name(source, idx), true),
|
||||
idx,
|
||||
source,
|
||||
#[cfg(windows)]
|
||||
dxgi_recovery_state: Arc::new(Mutex::new(DxgiRecoveryState::new())),
|
||||
};
|
||||
GenericService::run(&vs, run);
|
||||
vs.sp
|
||||
@@ -565,9 +624,25 @@ fn run(vs: VideoService) -> ResultType<()> {
|
||||
let last_portable_service_running = false;
|
||||
|
||||
let display_idx = vs.idx;
|
||||
#[cfg(windows)]
|
||||
let dxgi_recovery_state = vs.dxgi_recovery_state.clone();
|
||||
let sp = vs.sp;
|
||||
let mut c = get_capturer(vs.source, display_idx, last_portable_service_running)?;
|
||||
#[cfg(windows)]
|
||||
// ACCESS_LOST marks the next successful capturer creation as a recovery. This timestamp is
|
||||
// consumed once and temporarily holds off the normal WouldBlock-to-GDI fallback, giving the
|
||||
// replacement DXGI capturer time to produce its first frame. Normal startup is unaffected.
|
||||
let dxgi_recovery_started = dxgi_recovery_state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take_restart_pending()
|
||||
.then(Instant::now);
|
||||
#[cfg(windows)]
|
||||
if dxgi_recovery_state.lock().unwrap().take_fallback_pending() {
|
||||
c.set_gdi();
|
||||
log::info!("dxgi recovery exhausted, fall back to gdi");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if !scrap::codec::enable_directx_capture() && !c.is_gdi() {
|
||||
log::info!("disable dxgi with option, fall back to gdi");
|
||||
c.set_gdi();
|
||||
@@ -655,12 +730,6 @@ fn run(vs: VideoService) -> ResultType<()> {
|
||||
let capture_width = c.width;
|
||||
let capture_height = c.height;
|
||||
let (mut second_instant, mut send_counter) = (Instant::now(), 0);
|
||||
// Diagnostics only. `send_counter` counts capture rounds, which is not the
|
||||
// number of frames that reached a connection: the encoder's own rate control
|
||||
// drops frames when the bitrate cannot carry them. `wait_max_ms` is how long
|
||||
// a round waited for the previous frame to be picked up, so a blocked write
|
||||
// shows up here as capture stalling rather than as a slow network.
|
||||
let (mut sent_counter, mut wait_max_ms) = (0usize, 0u32);
|
||||
|
||||
while sp.ok() {
|
||||
#[cfg(windows)]
|
||||
@@ -671,8 +740,6 @@ fn run(vs: VideoService) -> ResultType<()> {
|
||||
&mut spf,
|
||||
client_record,
|
||||
&mut send_counter,
|
||||
&mut sent_counter,
|
||||
&mut wait_max_ms,
|
||||
&mut second_instant,
|
||||
&sp.name(),
|
||||
)?;
|
||||
@@ -793,9 +860,6 @@ fn run(vs: VideoService) -> ResultType<()> {
|
||||
capture_width,
|
||||
capture_height,
|
||||
)?;
|
||||
if !send_conn_ids.is_empty() {
|
||||
sent_counter += 1;
|
||||
}
|
||||
frame_controller.set_send(now, send_conn_ids);
|
||||
send_counter += 1;
|
||||
}
|
||||
@@ -815,13 +879,18 @@ fn run(vs: VideoService) -> ResultType<()> {
|
||||
match res {
|
||||
Err(ref e) if e.kind() == WouldBlock => {
|
||||
#[cfg(windows)]
|
||||
if try_gdi > 0 && !c.is_gdi() {
|
||||
if try_gdi > 3 {
|
||||
c.set_gdi();
|
||||
try_gdi = 0;
|
||||
log::info!("No image, fall back to gdi");
|
||||
if dxgi_recovery_started
|
||||
.map(|started| started.elapsed() >= DXGI_RECOVERY_FRAME_GRACE)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
if try_gdi > 0 && !c.is_gdi() {
|
||||
if try_gdi > 3 {
|
||||
c.set_gdi();
|
||||
try_gdi = 0;
|
||||
log::info!("No image, fall back to gdi");
|
||||
}
|
||||
try_gdi += 1;
|
||||
}
|
||||
try_gdi += 1;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -855,15 +924,19 @@ fn run(vs: VideoService) -> ResultType<()> {
|
||||
capture_width,
|
||||
capture_height,
|
||||
)?;
|
||||
if !send_conn_ids.is_empty() {
|
||||
sent_counter += 1;
|
||||
}
|
||||
frame_controller.set_send(now, send_conn_ids);
|
||||
send_counter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
#[cfg(windows)]
|
||||
// The display-change check can restart capture before error handling below.
|
||||
let recovery_attempt = if !c.is_gdi() && err.kind() == ConnectionReset {
|
||||
dxgi_recovery_state.lock().unwrap().next_attempt()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// This check may be redundant, but it is better to be safe.
|
||||
// The previous check in `sp.is_option_true(OPTION_REFRESH)` block may be enough.
|
||||
if vs.source.is_monitor() {
|
||||
@@ -872,6 +945,19 @@ fn run(vs: VideoService) -> ResultType<()> {
|
||||
|
||||
#[cfg(windows)]
|
||||
if !c.is_gdi() {
|
||||
if err.kind() == ConnectionReset {
|
||||
if let Some(attempt) = recovery_attempt {
|
||||
log::debug!(
|
||||
"dxgi access lost, restart capture: attempt {attempt}, error: {err:?}"
|
||||
);
|
||||
bail!("SWITCH");
|
||||
}
|
||||
log::warn!(
|
||||
"dxgi access lost after {DXGI_RECOVERY_LIMIT} restarts in {} seconds, fall back to gdi: {err:?}",
|
||||
DXGI_RECOVERY_WINDOW.as_secs()
|
||||
);
|
||||
dxgi_recovery_state.lock().unwrap().take_fallback_pending();
|
||||
}
|
||||
c.set_gdi();
|
||||
log::info!("dxgi error, fall back to gdi: {:?}", err);
|
||||
continue;
|
||||
@@ -899,7 +985,6 @@ fn run(vs: VideoService) -> ResultType<()> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
wait_max_ms = wait_max_ms.max(wait_begin.elapsed().as_millis() as u32);
|
||||
DISPLAY_CONN_IDS.lock().unwrap().remove(&display_idx);
|
||||
|
||||
let elapsed = now.elapsed();
|
||||
@@ -1330,22 +1415,12 @@ pub fn make_display_changed_msg(
|
||||
Some(msg_out)
|
||||
}
|
||||
|
||||
/// Per-second pipeline diagnostics, off unless `RUSTDESK_QOS_VERBOSE` is set.
|
||||
/// The default log level is `debug`, so an unconditional line here would land in
|
||||
/// every user's log file once a second forever. Nothing enables it implicitly.
|
||||
pub(crate) fn qos_diag_verbose() -> bool {
|
||||
static VERBOSE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*VERBOSE.get_or_init(|| std::env::var("RUSTDESK_QOS_VERBOSE").is_ok())
|
||||
}
|
||||
|
||||
fn check_qos(
|
||||
encoder: &mut Encoder,
|
||||
ratio: &mut f32,
|
||||
spf: &mut Duration,
|
||||
client_record: bool,
|
||||
send_counter: &mut usize,
|
||||
sent_counter: &mut usize,
|
||||
wait_max_ms: &mut u32,
|
||||
second_instant: &mut Instant,
|
||||
name: &str,
|
||||
) -> ResultType<()> {
|
||||
@@ -1371,21 +1446,7 @@ fn check_qos(
|
||||
if second_instant.elapsed() > Duration::from_secs(1) {
|
||||
*second_instant = Instant::now();
|
||||
video_qos.update_display_data(&name, *send_counter);
|
||||
// Diagnostics only, joined with `qos_trace` on `t`: the controller's target
|
||||
// is not the rate the encoder produced, and neither is the rate the send
|
||||
// path accepted.
|
||||
if qos_diag_verbose() {
|
||||
log::debug!(
|
||||
"qos_video t={} display={name} captured={} sent={} wait_max={}",
|
||||
hbb_common::get_time(),
|
||||
*send_counter,
|
||||
*sent_counter,
|
||||
*wait_max_ms
|
||||
);
|
||||
}
|
||||
*send_counter = 0;
|
||||
*sent_counter = 0;
|
||||
*wait_max_ms = 0;
|
||||
}
|
||||
drop(video_qos);
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user