mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-13 07:51:01 +03:00
Compare commits
34 Commits
2f342e7730
...
fix-unzoom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8da629c57d | ||
|
|
650a6e21cc | ||
|
|
5f4bb00007 | ||
|
|
a7f1eb4c25 | ||
|
|
be5fb304d4 | ||
|
|
fdd67a875b | ||
|
|
39940b717a | ||
|
|
67b94c7906 | ||
|
|
eeff9eb121 | ||
|
|
c983d00437 | ||
|
|
7bb3fe6b5a | ||
|
|
d202a2fba4 | ||
|
|
72ea38ca4f | ||
|
|
f96d00d9d1 | ||
|
|
f5b98b32f2 | ||
|
|
8aee2a442e | ||
|
|
12eaf2cc75 | ||
|
|
7194743a30 | ||
|
|
5cfe136fb0 | ||
|
|
14a5ed45d9 | ||
|
|
435fe24a81 | ||
|
|
3ffee7c1ff | ||
|
|
97190f715b | ||
|
|
aa232a9dfa | ||
|
|
65edf214b9 | ||
|
|
bac8323e5d | ||
|
|
f164c9a9df | ||
|
|
080211ff36 | ||
|
|
01dbb76499 | ||
|
|
68359a2dd2 | ||
|
|
5228f91982 | ||
|
|
691830fe89 | ||
|
|
22b1ed169a | ||
|
|
0f0205d336 |
42
.github/scripts/sign-macos-app.sh
vendored
Normal file
42
.github/scripts/sign-macos-app.sh
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
app_path=$1
|
||||
identity=$2
|
||||
entitlements=$3
|
||||
|
||||
sign_args=(--force --options runtime --sign "$identity")
|
||||
if [[ "$identity" != "-" ]]; then
|
||||
sign_args+=(--timestamp)
|
||||
fi
|
||||
|
||||
frameworks_path="$app_path/Contents/Frameworks"
|
||||
if [[ -d "$frameworks_path" ]]; then
|
||||
while IFS= read -r -d '' code; do
|
||||
if file -b "$code" | grep -q 'Mach-O'; then
|
||||
codesign "${sign_args[@]}" "$code"
|
||||
fi
|
||||
done < <(find "$frameworks_path" -type f -print0)
|
||||
|
||||
while IFS= read -r -d '' framework; do
|
||||
codesign "${sign_args[@]}" "$framework"
|
||||
done < <(find "$frameworks_path" -depth -type d -name '*.framework' -print0)
|
||||
fi
|
||||
|
||||
service_path="$app_path/Contents/MacOS/service"
|
||||
if [[ -f "$service_path" ]]; then
|
||||
codesign "${sign_args[@]}" "$service_path"
|
||||
fi
|
||||
|
||||
codesign "${sign_args[@]}" --generate-entitlement-der \
|
||||
--entitlements "$entitlements" "$app_path"
|
||||
codesign --verify --deep --strict --verbose=2 "$app_path"
|
||||
|
||||
actual_entitlements=$(codesign -d --entitlements :- "$app_path" 2>/dev/null)
|
||||
audio_input=$(plutil -extract 'com\.apple\.security\.device\.audio-input' raw - \
|
||||
<<<"$actual_entitlements")
|
||||
if [[ "$audio_input" != "true" ]]; then
|
||||
echo "Missing com.apple.security.device.audio-input entitlement" >&2
|
||||
exit 1
|
||||
fi
|
||||
6
.github/workflows/flutter-build.yml
vendored
6
.github/workflows/flutter-build.yml
vendored
@@ -926,7 +926,11 @@ jobs:
|
||||
security unlock-keychain -p ${{ secrets.MACOS_P12_PASSWORD }} rustdesk.keychain
|
||||
# start sign the rustdesk.app and dmg
|
||||
rm -rf *.dmg || true
|
||||
codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict ./flutter/build/macos/Build/Products/Release/RustDesk.app -vvv
|
||||
# the identity secret carries its own shell quoting, so expand it inline like the dmg codesign below
|
||||
bash ./.github/scripts/sign-macos-app.sh \
|
||||
./flutter/build/macos/Build/Products/Release/RustDesk.app \
|
||||
${{ secrets.MACOS_CODESIGN_IDENTITY }} \
|
||||
./flutter/macos/Runner/Release.entitlements
|
||||
create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app
|
||||
codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv
|
||||
# notarize the rustdesk-${{ env.VERSION }}.dmg
|
||||
|
||||
8
Cargo.lock
generated
8
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#6b374bcaed076750ca8fce6da518ab39b882e14a"
|
||||
source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#69ad2578adc9200093fc81cdfbdad63dbc4274f9"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"cidre",
|
||||
@@ -4280,7 +4280,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "kcp-sys"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/rustdesk-org/kcp-sys?branch=rustdesk-patches#023a0065398968989f2ddfcf5cc72bb886d02675"
|
||||
source = "git+https://github.com/rustdesk-org/kcp-sys?branch=rustdesk-patches#938eda3e5e9757a612385503af7a6cb1189b2cdd"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"auto_impl",
|
||||
@@ -9731,7 +9731,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-sctp"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=b221f13b1d6f21fbce09f9f63096be27dd392265#b221f13b1d6f21fbce09f9f63096be27dd392265"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=db3b07a9dd8f195916c89c2e62a8911402b11d27#db3b07a9dd8f195916c89c2e62a8911402b11d27"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9771,7 +9771,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "webrtc-util"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=b221f13b1d6f21fbce09f9f63096be27dd392265#b221f13b1d6f21fbce09f9f63096be27dd392265"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=db3b07a9dd8f195916c89c2e62a8911402b11d27#db3b07a9dd8f195916c89c2e62a8911402b11d27"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags 1.3.2",
|
||||
|
||||
@@ -230,8 +230,8 @@ libxdo-sys = { path = "libs/libxdo-sys-stub" }
|
||||
# the SACK settle the rest (F-RTO), timed from the latest send, so a stall no longer resends the
|
||||
# whole backlog behind itself while a short lost tail still comes back at once.
|
||||
# Pinned by rev, not branch: a fork branch can be rewritten out from under the lockfile.
|
||||
webrtc-util = { git = "https://github.com/rustdesk-org/webrtc", rev = "b221f13b1d6f21fbce09f9f63096be27dd392265" }
|
||||
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "b221f13b1d6f21fbce09f9f63096be27dd392265" }
|
||||
webrtc-util = { git = "https://github.com/rustdesk-org/webrtc", rev = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
|
||||
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||
|
||||
11
fastlane/metadata/android/es-ES/full_description.txt
Normal file
11
fastlane/metadata/android/es-ES/full_description.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Aplicación de escritorio remoto de código abierto, la alternativa open source a TeamViewer.
|
||||
Código fuente: https://github.com/rustdesk/rustdesk
|
||||
Documentación: https://rustdesk.com/docs/en/manual/mobile/
|
||||
|
||||
Para que un dispositivo remoto controle tu Android mediante el ratón o el tacto, debes permitir que RustDesk utilice el servicio de "Accesibilidad". RustDesk utiliza la API AccessibilityService para implementar el control remoto en Android.
|
||||
|
||||
Además del control remoto, también puedes transferir archivos fácilmente entre dispositivos Android y ordenadores mediante RustDesk.
|
||||
|
||||
Tienes control total de tus datos, sin preocupaciones de seguridad. Puedes utilizar nuestro servidor rendezvous/relay, optar por el autoalojamiento o escribir tu propio servidor rendezvous/relay. El servidor autoalojado es gratuito y de código abierto: https://github.com/rustdesk/rustdesk-server
|
||||
|
||||
Descarga e instala la versión de escritorio desde: https://rustdesk.com — entonces podrás acceder y controlar tu ordenador desde tu teléfono, o controlar tu teléfono desde tu ordenador.
|
||||
1
fastlane/metadata/android/es-ES/short_description.txt
Normal file
1
fastlane/metadata/android/es-ES/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Aplicación de acceso remoto de código abierto, alternativa a TeamViewer.
|
||||
11
fastlane/metadata/android/pt-BR/full_description.txt
Normal file
11
fastlane/metadata/android/pt-BR/full_description.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Aplicativo de desktop remoto de código aberto, a alternativa open source ao TeamViewer.
|
||||
Código-fonte: https://github.com/rustdesk/rustdesk
|
||||
Documentação: https://rustdesk.com/docs/pt/client/android/
|
||||
|
||||
Para que um dispositivo remoto controle seu Android via mouse ou toque, você precisa permitir que o RustDesk utilize o serviço de "Acessibilidade". O RustDesk usa a API AccessibilityService para implementar o controle remoto no Android.
|
||||
|
||||
Além do controle remoto, você também pode transferir arquivos entre dispositivos Android e PCs com facilidade usando o RustDesk.
|
||||
|
||||
Você tem controle total dos seus dados, sem preocupações com a segurança. Você pode usar nosso servidor rendezvous/relay, optar pela auto-hospedagem ou criar seu próprio servidor de rendezvous/relay. O servidor auto-hospedado é gratuito e open source: https://github.com/rustdesk/rustdesk-server
|
||||
|
||||
Baixe e instale a versão para desktop em: https://rustdesk.com — então você poderá acessar e controlar seu computador pelo celular ou controlar seu celular pelo computador.
|
||||
1
fastlane/metadata/android/pt-BR/short_description.txt
Normal file
1
fastlane/metadata/android/pt-BR/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Aplicativo de acesso remoto open source, alternativa ao TeamViewer.
|
||||
@@ -1101,6 +1101,9 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
final m = Provider.of<ImageModel>(context);
|
||||
var c = Provider.of<CanvasModel>(context);
|
||||
final s = c.scale;
|
||||
// CanvasModel caches the DPR and only refreshes it when the view style
|
||||
// changes, so read it live to follow the window across monitors.
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
|
||||
bool isViewAdaptive() => c.viewStyle.style == kRemoteViewStyleAdaptive;
|
||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
@@ -1117,6 +1120,12 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
} else {
|
||||
if (zoomCursor.value || isViewOriginal()) {
|
||||
cursorScale = s;
|
||||
} else {
|
||||
// NSCursor and GdkCursor treat the bitmap size as logical
|
||||
// pixels, so an unzoomed cursor must be shrunk by the DPR to
|
||||
// keep 1 remote px == 1 physical px, the size Original view
|
||||
// already renders it at.
|
||||
cursorScale = 1.0 / dpr;
|
||||
}
|
||||
}
|
||||
return cursorScale;
|
||||
@@ -1404,14 +1413,29 @@ class CursorPaint extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
double x = (m.x - hotx) * c.scale + cx;
|
||||
double y = (m.y - hoty) * c.scale + cy;
|
||||
double x = m.x * c.scale + cx - hotx;
|
||||
double y = m.y * c.scale + cy - hoty;
|
||||
double scale = 1.0;
|
||||
final isViewOriginal = c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
if (zoomCursor.value || isViewOriginal) {
|
||||
x = m.x - hotx + cx / c.scale;
|
||||
y = m.y - hoty + cy / c.scale;
|
||||
scale = c.scale;
|
||||
} else if (!isWindows) {
|
||||
// Keep the painted cursor the same physical size as the native one
|
||||
// built by getCursorScale() above, including its min-size clamp.
|
||||
scale = 1.0 / MediaQuery.devicePixelRatioOf(context);
|
||||
final image = m.image ?? preDefaultCursor.image;
|
||||
if (scale != 1.0 &&
|
||||
image != null &&
|
||||
((image.width * scale).toInt() < kMinCursorSize ||
|
||||
(image.height * scale).toInt() < kMinCursorSize)) {
|
||||
final sw = kMinCursorSize / image.width;
|
||||
final sh = kMinCursorSize / image.height;
|
||||
scale = sw < sh ? sh : sw;
|
||||
}
|
||||
x = (m.x * c.scale + cx) / scale - hotx;
|
||||
y = (m.y * c.scale + cy) / scale - hoty;
|
||||
}
|
||||
|
||||
return CustomPaint(
|
||||
|
||||
@@ -896,9 +896,13 @@ class FfiModel with ChangeNotifier {
|
||||
final text = evt['text'];
|
||||
final link = evt['link'];
|
||||
|
||||
// The peer-gone detector reconnects under `restarting-show` rather than an error title, so
|
||||
// it needs naming here too. By its own title, not the type: an explicitly restarted remote
|
||||
// device reaches the same type from a path this change does not touch.
|
||||
if (isAndroid &&
|
||||
_androidDocumentPickerActive &&
|
||||
title == 'Connection Error') {
|
||||
(title == 'Connection Error' ||
|
||||
(type == 'restarting-show' && title == 'Connecting...'))) {
|
||||
_androidDocumentPickerInterruptedConnection = true;
|
||||
return;
|
||||
}
|
||||
@@ -2880,7 +2884,7 @@ class CursorData {
|
||||
if (scale != 1.0) {
|
||||
// Update data if scale changed.
|
||||
final tgtWidth = (width * scale).toInt();
|
||||
final tgtHeight = (width * scale).toInt();
|
||||
final tgtHeight = (height * scale).toInt();
|
||||
if (tgtWidth < kMinCursorSize || tgtHeight < kMinCursorSize) {
|
||||
double sw = kMinCursorSize.toDouble() / width;
|
||||
double sh = kMinCursorSize.toDouble() / height;
|
||||
|
||||
@@ -306,7 +306,7 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
ref: bd6b5b41254e57c5bcece202ebfb234de63e6487
|
||||
resolved-ref: bd6b5b41254e57c5bcece202ebfb234de63e6487
|
||||
url: "https://github.com/rustdesk-org/Dash-Chat-2"
|
||||
source: git
|
||||
@@ -339,8 +339,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: 8b774a66671cbb9bcb2631af6ac28f9bdd469ce3
|
||||
ref: "8b774a66671cbb9bcb2631af6ac28f9bdd469ce3"
|
||||
resolved-ref: "8b774a66671cbb9bcb2631af6ac28f9bdd469ce3"
|
||||
url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
@@ -1581,7 +1581,7 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
ref: cf4aef0512092fad9344a27ffe1c47ad83269dfc
|
||||
resolved-ref: cf4aef0512092fad9344a27ffe1c47ad83269dfc
|
||||
url: "https://github.com/rustdesk-org/window_manager"
|
||||
source: git
|
||||
|
||||
@@ -40,6 +40,7 @@ dependencies:
|
||||
dash_chat_2:
|
||||
git:
|
||||
url: https://github.com/rustdesk-org/Dash-Chat-2
|
||||
ref: bd6b5b41254e57c5bcece202ebfb234de63e6487
|
||||
draggable_float_widget: ^0.1.0
|
||||
settings_ui: ^2.0.2
|
||||
flutter_breadcrumb: ^1.0.1
|
||||
@@ -53,9 +54,11 @@ dependencies:
|
||||
window_manager:
|
||||
git:
|
||||
url: https://github.com/rustdesk-org/window_manager
|
||||
ref: cf4aef0512092fad9344a27ffe1c47ad83269dfc
|
||||
desktop_multi_window:
|
||||
git:
|
||||
url: https://github.com/rustdesk-org/rustdesk_desktop_multi_window
|
||||
ref: 8b774a66671cbb9bcb2631af6ac28f9bdd469ce3
|
||||
freezed_annotation: ^2.0.3
|
||||
flutter_custom_cursor:
|
||||
git:
|
||||
|
||||
Submodule libs/hbb_common updated: 55395c6fcb...29cf7cbe4d
@@ -265,10 +265,13 @@ pub struct PipeWireRecorder {
|
||||
}
|
||||
|
||||
// Element creation fails the same way for a plugin that is not installed as for one that is
|
||||
// broken, and the name is the only thing that tells a user which package to look at.
|
||||
// broken, so the tag does not claim which. Only the name travels to the peer -- it is what
|
||||
// says which package to look at -- and the factory's own error stays here in the log.
|
||||
fn gst_element(name: &str) -> ResultType<gst::Element> {
|
||||
gst::ElementFactory::make(name, None)
|
||||
.map_err(|_| anyhow!(stage_err("gst-plugin", "missing", name)))
|
||||
gst::ElementFactory::make(name, None).map_err(|e| {
|
||||
error!("Failed to create GStreamer element {}: {}", name, e);
|
||||
anyhow!(stage_err("gst-plugin", "unavailable", name))
|
||||
})
|
||||
}
|
||||
|
||||
impl PipeWireRecorder {
|
||||
@@ -481,6 +484,7 @@ enum PortalStage {
|
||||
SelectDevices = 2,
|
||||
SelectSources = 3,
|
||||
Start = 4,
|
||||
OpenPipeWireRemote = 5,
|
||||
}
|
||||
|
||||
impl PortalStage {
|
||||
@@ -490,6 +494,7 @@ impl PortalStage {
|
||||
Self::SelectDevices => "select-devices",
|
||||
Self::SelectSources => "select-sources",
|
||||
Self::Start => "start",
|
||||
Self::OpenPipeWireRemote => "open-pipewire-remote",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,6 +503,7 @@ impl PortalStage {
|
||||
2 => Self::SelectDevices,
|
||||
3 => Self::SelectSources,
|
||||
4 => Self::Start,
|
||||
5 => Self::OpenPipeWireRemote,
|
||||
_ => Self::CreateSession,
|
||||
}
|
||||
}
|
||||
@@ -506,6 +512,8 @@ impl PortalStage {
|
||||
// `wl-stage:<stage>:<kind>:<detail>`, parsed by `map_err_scrap` on the app side. The detail
|
||||
// reaches the user through a `{}` placeholder in a translated string, so it must not bring
|
||||
// braces, control characters or unbounded length of its own.
|
||||
const STAGE_TAG: &str = "wl-stage:";
|
||||
|
||||
fn stage_err(stage: &str, kind: &str, detail: &str) -> String {
|
||||
let detail: String = detail
|
||||
.chars()
|
||||
@@ -513,7 +521,7 @@ fn stage_err(stage: &str, kind: &str, detail: &str) -> String {
|
||||
.filter(|c| *c != '{' && *c != '}')
|
||||
.take(200)
|
||||
.collect();
|
||||
format!("wl-stage:{}:{}:{}", stage, kind, detail.trim())
|
||||
format!("{}{}:{}:{}", STAGE_TAG, stage, kind, detail.trim())
|
||||
}
|
||||
|
||||
// The name alone is usually the generic `org.freedesktop.DBus.Error.Failed`; the message is
|
||||
@@ -526,7 +534,12 @@ fn dbus_stage_err(stage: &str, err: &dbus::Error) -> String {
|
||||
(Some(name), _) if !name.is_empty() => name.to_owned(),
|
||||
(_, message) => message.unwrap_or_default().to_owned(),
|
||||
};
|
||||
stage_err(stage, "dbus", &detail)
|
||||
let kind = match err.name().unwrap_or_default() {
|
||||
"org.freedesktop.DBus.Error.UnknownMethod"
|
||||
| "org.freedesktop.DBus.Error.UnknownInterface" => "unsupported",
|
||||
_ => "dbus",
|
||||
};
|
||||
stage_err(stage, kind, &detail)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -605,6 +618,11 @@ where
|
||||
trace.fail(stage, "declined", "");
|
||||
return true;
|
||||
}
|
||||
2 => {
|
||||
warn!("DBus response: User interaction ended in some other way.");
|
||||
trace.fail(stage, "ended", "");
|
||||
return true;
|
||||
}
|
||||
c => {
|
||||
warn!("DBus response: Unknown error, code: {}.", c);
|
||||
trace.fail(stage, "portal-error", &c.to_string());
|
||||
@@ -612,8 +630,14 @@ where
|
||||
}
|
||||
}
|
||||
if let Err(err) = f(r, c, m) {
|
||||
warn!("Error requesting screen capture via dbus: {}", err);
|
||||
trace.fail(trace.waiting_stage(), "internal", &err.to_string());
|
||||
let text = err.to_string();
|
||||
warn!("Error requesting screen capture via dbus: {}", text);
|
||||
if text.starts_with(STAGE_TAG) {
|
||||
trace.record(text);
|
||||
trace.failed.store(true, Ordering::SeqCst);
|
||||
} else {
|
||||
trace.fail(trace.waiting_stage(), "internal", &text);
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
@@ -912,6 +936,7 @@ fn on_create_session_response(
|
||||
});
|
||||
}
|
||||
|
||||
trace.waiting(PortalStage::SelectSources);
|
||||
handle_response(
|
||||
c,
|
||||
get_request_path(c, select_sources_handle_token)?,
|
||||
@@ -925,8 +950,9 @@ fn on_create_session_response(
|
||||
trace.clone(),
|
||||
PortalStage::SelectSources,
|
||||
)?;
|
||||
trace.waiting(PortalStage::SelectSources);
|
||||
let _ = portal.select_sources(ses.clone(), args)?;
|
||||
let _ = portal
|
||||
.select_sources(ses.clone(), args)
|
||||
.map_err(|e| DBusError(dbus_stage_err("select-sources", &e)))?;
|
||||
} else {
|
||||
// TODO: support persist_mode for remote_desktop_portal
|
||||
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html
|
||||
@@ -938,6 +964,7 @@ fn on_create_session_response(
|
||||
);
|
||||
args.insert("types".to_string(), Variant(Box::new(7u32)));
|
||||
|
||||
trace.waiting(PortalStage::SelectDevices);
|
||||
handle_response(
|
||||
c,
|
||||
get_request_path(c, select_devices_handle_token)?,
|
||||
@@ -951,8 +978,9 @@ fn on_create_session_response(
|
||||
trace.clone(),
|
||||
PortalStage::SelectDevices,
|
||||
)?;
|
||||
trace.waiting(PortalStage::SelectDevices);
|
||||
let _ = portal.select_devices(ses.clone(), args)?;
|
||||
let _ = portal
|
||||
.select_devices(ses.clone(), args)
|
||||
.map_err(|e| DBusError(dbus_stage_err("select-devices", &e)))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -985,6 +1013,7 @@ fn on_select_devices_response(
|
||||
args.insert("types".into(), Variant(Box::new(1u32))); //| 2u32)));
|
||||
|
||||
let session = session.clone();
|
||||
trace.waiting(PortalStage::SelectSources);
|
||||
handle_response(
|
||||
c,
|
||||
get_request_path(c, select_sources_handle_token)?,
|
||||
@@ -998,8 +1027,9 @@ fn on_select_devices_response(
|
||||
trace.clone(),
|
||||
PortalStage::SelectSources,
|
||||
)?;
|
||||
trace.waiting(PortalStage::SelectSources);
|
||||
let _ = portal.select_sources(session.clone(), args)?;
|
||||
let _ = portal
|
||||
.select_sources(session.clone(), args)
|
||||
.map_err(|e| DBusError(dbus_stage_err("select-sources", &e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1024,6 +1054,7 @@ fn on_select_sources_response(
|
||||
"handle_token".to_string(),
|
||||
Variant(Box::new(start_handle_token.to_string())),
|
||||
);
|
||||
trace.waiting(PortalStage::Start);
|
||||
handle_response(
|
||||
c,
|
||||
get_request_path(c, start_handle_token)?,
|
||||
@@ -1031,16 +1062,18 @@ fn on_select_sources_response(
|
||||
fd.clone(),
|
||||
streams.clone(),
|
||||
session.clone(),
|
||||
trace.clone(),
|
||||
is_support_restore_token,
|
||||
),
|
||||
trace.clone(),
|
||||
PortalStage::Start,
|
||||
)?;
|
||||
trace.waiting(PortalStage::Start);
|
||||
if is_server_running() {
|
||||
let _ = screencast_portal::start(&portal, session.clone(), "", args)?;
|
||||
let _ = screencast_portal::start(&portal, session.clone(), "", args)
|
||||
.map_err(|e| DBusError(dbus_stage_err("start", &e)))?;
|
||||
} else {
|
||||
let _ = remote_desktop_portal::start(&portal, session.clone(), "", args)?;
|
||||
let _ = remote_desktop_portal::start(&portal, session.clone(), "", args)
|
||||
.map_err(|e| DBusError(dbus_stage_err("start", &e)))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1051,6 +1084,7 @@ fn on_start_response(
|
||||
fd: Arc<Mutex<Option<OwnedFd>>>,
|
||||
streams: Arc<Mutex<Vec<PwStreamInfo>>>,
|
||||
session: dbus::Path<'static>,
|
||||
trace: PortalTrace,
|
||||
is_support_restore_token: bool,
|
||||
) -> impl Fn(
|
||||
OrgFreedesktopPortalRequestResponse,
|
||||
@@ -1078,10 +1112,14 @@ fn on_start_response(
|
||||
.lock()
|
||||
.unwrap()
|
||||
.append(&mut streams_from_response(r));
|
||||
fd.clone()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace(portal.open_pipe_wire_remote(session.clone(), HashMap::new())?);
|
||||
// Past this point the user has granted the request; anything that fails now is the
|
||||
// hand-over of the PipeWire fd, which is a different thing to go looking at.
|
||||
trace.waiting(PortalStage::OpenPipeWireRemote);
|
||||
fd.clone().lock().unwrap().replace(
|
||||
portal
|
||||
.open_pipe_wire_remote(session.clone(), HashMap::new())
|
||||
.map_err(|e| DBusError(dbus_stage_err("open-pipewire-remote", &e)))?,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ use crate::{
|
||||
// Restart msgbox text is kept as a legacy UI fallback; Flutter handles the type as a control event.
|
||||
const RESTART_REMOTE_DEVICE_NO_DATA_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const KCP_CLOSE_REASON_FLUSH_DELAY: Duration = Duration::from_millis(30);
|
||||
// Deadline for the parting close-reason send once the peer is presumed gone; KCP waits for send
|
||||
// capacity with no deadline of its own.
|
||||
const KCP_CLOSE_REASON_GONE_DEADLINE: Duration = Duration::from_millis(500);
|
||||
// Grace after ICE reports Disconnected, which it does ~5s after it stops hearing from the peer,
|
||||
// for ~8s in total. Disconnected is transient by design, so this waits out a Wi-Fi roam or a
|
||||
// sleep/wake rather than acting on the first hint.
|
||||
const WEBRTC_SUSPECT_GRACE: Duration = Duration::from_secs(3);
|
||||
// KCP gets no such hint, only how long since a packet arrived; its endpoint pings an idle peer
|
||||
// about every 2s, so this is several missed pings, and matches the 8s WebRTC arrives at.
|
||||
const KCP_PEER_SILENCE_LIMIT: Duration = Duration::from_secs(8);
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip};
|
||||
use base::{
|
||||
@@ -247,6 +257,9 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
|
||||
let _keep_it = client::hc_connection(feedback, rendezvous_server, token).await;
|
||||
let mut last_recv_time = Instant::now();
|
||||
let mut webrtc_suspect_since: Option<Instant> = None;
|
||||
let mut last_rx_progress = peer.rx_progress();
|
||||
let mut peer_gone = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -313,6 +326,37 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
self.handler.msgbox("restarting-show", "Restarting remote device", "Connection in progress. Please wait.", "");
|
||||
break;
|
||||
}
|
||||
let rx_progress = peer.rx_progress();
|
||||
// `None` for transports that report none, and it never changes for a
|
||||
// given one, so they are inert here.
|
||||
let progressed = rx_progress != last_rx_progress;
|
||||
last_rx_progress = rx_progress;
|
||||
if peer.webrtc_disconnected() && !progressed {
|
||||
webrtc_suspect_since.get_or_insert_with(Instant::now);
|
||||
} else {
|
||||
webrtc_suspect_since = None;
|
||||
}
|
||||
// Neither limit is a hard upper bound. A send is awaited inline in
|
||||
// this loop, so one in progress delays this tick - bounded on WebRTC
|
||||
// by the timeout the stream was built with, not bounded at all on
|
||||
// KCP. The 30s watchdog above shares the loop and the same delay.
|
||||
peer_gone = webrtc_suspect_since
|
||||
.map_or(false, |since| since.elapsed() >= WEBRTC_SUSPECT_GRACE)
|
||||
|| kcp
|
||||
.as_ref()
|
||||
.and_then(|k| k.peer_silent_for())
|
||||
.map_or(false, |silent| silent >= KCP_PEER_SILENCE_LIMIT);
|
||||
if peer_gone {
|
||||
log::info!("Peer stopped answering, reconnecting");
|
||||
#[cfg(feature = "flutter")]
|
||||
self.handler.msgbox("restarting-show", "Connecting...", "Connection in progress. Please wait.", "");
|
||||
// Sciter knows no `restarting-show` and would show a dialog that
|
||||
// waits for a click, where the timeout this arrives ahead of is
|
||||
// retryable and reconnects on its own. Keep that message for it.
|
||||
#[cfg(not(feature = "flutter"))]
|
||||
self.handler.msgbox("error", "Connection Error", "Timeout", "");
|
||||
break;
|
||||
}
|
||||
let elapsed = fps_instant.elapsed().as_millis();
|
||||
if elapsed < 1000 {
|
||||
continue;
|
||||
@@ -358,6 +402,11 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
s.send(()).ok();
|
||||
}
|
||||
if kcp.is_some() {
|
||||
// Attempted rather than skipped even here: if the loss was one-way the peer
|
||||
// does get it, and drops its side instead of waiting out its own timeout.
|
||||
if peer_gone {
|
||||
peer.set_send_timeout(KCP_CLOSE_REASON_GONE_DEADLINE.as_millis() as u64);
|
||||
}
|
||||
// Send the close reason if it hasn't been sent yet, as KCP cannot detect the socket close event.
|
||||
self.send_close_reason(&mut peer, "kcp").await;
|
||||
// KCP does not send messages immediately, so wait to ensure the last message is sent.
|
||||
|
||||
@@ -59,11 +59,14 @@ impl Screenshot {
|
||||
}
|
||||
|
||||
fn handle_screenshot(&mut self, action: String) -> String {
|
||||
let Some(data) = self.data.take() else {
|
||||
let Some(data) = self.data.as_ref().cloned() else {
|
||||
return "No cached screenshot".to_owned();
|
||||
};
|
||||
match Self::handle_screenshot_(data, action) {
|
||||
Ok(()) => "".to_owned(),
|
||||
Ok(()) => {
|
||||
self.data = None;
|
||||
"".to_owned()
|
||||
}
|
||||
Err(e) => e.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -98,3 +101,37 @@ pub fn set_screenshot(data: bytes::Bytes) {
|
||||
pub fn handle_screenshot(action: String) -> String {
|
||||
SCREENSHOT.lock().unwrap().handle_screenshot(action)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Screenshot;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn preserves_cached_screenshot_when_save_fails() {
|
||||
let data = bytes::Bytes::from_static(b"screenshot data");
|
||||
let mut screenshot = Screenshot {
|
||||
data: Some(data.clone()),
|
||||
};
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let missing_parent = std::env::temp_dir()
|
||||
.join(format!("rustdesk-screenshot-missing-parent-{unique}"))
|
||||
.join("screenshot.png");
|
||||
let valid_path = std::env::temp_dir().join(format!("rustdesk-screenshot-{unique}.png"));
|
||||
|
||||
let error = screenshot.handle_screenshot(format!("0:{}", missing_parent.display()));
|
||||
|
||||
assert!(!error.is_empty());
|
||||
assert_eq!(screenshot.data.as_deref(), Some(data.as_ref()));
|
||||
assert_eq!(
|
||||
screenshot.handle_screenshot(format!("0:{}", valid_path.display())),
|
||||
""
|
||||
);
|
||||
assert!(screenshot.data.is_none());
|
||||
assert_eq!(std::fs::read(&valid_path).unwrap(), data.as_ref());
|
||||
std::fs::remove_file(valid_path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,15 @@ use hbb_common::{
|
||||
tokio_util, ResultType, Stream,
|
||||
};
|
||||
use kcp_sys::{
|
||||
endpoint::KcpEndpoint,
|
||||
endpoint::{ConnId, KcpEndpoint},
|
||||
packet_def::{KcpPacket, KcpPacketHeader},
|
||||
stream,
|
||||
};
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
pub struct KcpStream {
|
||||
_endpoint: KcpEndpoint,
|
||||
endpoint: KcpEndpoint,
|
||||
conn_id: ConnId,
|
||||
stop_sender: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
@@ -41,6 +42,14 @@ impl KcpStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long since a valid packet was last received from the peer, or `None` once the
|
||||
/// connection is gone. Answered by the KCP endpoint's own tasks, not by the session's read
|
||||
/// loop, so it stays meaningful while that loop is busy sending a large message; and the
|
||||
/// endpoint pings an idle peer often enough that silence here means the peer, not quiet.
|
||||
pub fn peer_silent_for(&self) -> Option<std::time::Duration> {
|
||||
self.endpoint.peer_silent_for(&self.conn_id)
|
||||
}
|
||||
|
||||
fn create_framed(stream: stream::KcpStream, local_addr: Option<SocketAddr>) -> Stream {
|
||||
Stream::Tcp(FramedStream(
|
||||
tokio_util::codec::Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()),
|
||||
@@ -77,7 +86,8 @@ impl KcpStream {
|
||||
if let Some(stream) = stream::KcpStream::new(&endpoint, conn_id) {
|
||||
Ok((
|
||||
Self {
|
||||
_endpoint: endpoint,
|
||||
endpoint,
|
||||
conn_id,
|
||||
stop_sender: Some(stop_sender),
|
||||
},
|
||||
Self::create_framed(stream, udp_socket.local_addr().ok()),
|
||||
@@ -108,7 +118,8 @@ impl KcpStream {
|
||||
if let Some(stream) = stream::KcpStream::new(&endpoint, conn_id) {
|
||||
Ok((
|
||||
Self {
|
||||
_endpoint: endpoint,
|
||||
endpoint,
|
||||
conn_id,
|
||||
stop_sender: Some(stop_sender),
|
||||
},
|
||||
Self::create_framed(stream, udp_socket.local_addr().ok()),
|
||||
|
||||
@@ -2,6 +2,7 @@ use hbb_common::regex::Regex;
|
||||
use std::ops::Deref;
|
||||
|
||||
mod ar;
|
||||
mod az;
|
||||
mod be;
|
||||
mod bg;
|
||||
mod ca;
|
||||
@@ -105,6 +106,7 @@ pub const LANGS: &[(&str, &str)] = &[
|
||||
("ml", "മലയാളം"),
|
||||
("hi", "हिंदी"),
|
||||
("gu", "ગુજરાતી"),
|
||||
("az", "Azərbaycan dili"),
|
||||
];
|
||||
|
||||
pub(crate) fn cjk_ui_unavailable() -> bool {
|
||||
@@ -220,6 +222,7 @@ pub fn translate_locale(name: String, locale: &str) -> String {
|
||||
"hi" => hi::T.deref(),
|
||||
"gu" => gu::T.deref(),
|
||||
"gl" => gl::T.deref(),
|
||||
"az" => az::T.deref(),
|
||||
_ => en::T.deref(),
|
||||
};
|
||||
let (name, placeholder_value) = extract_placeholder(&name);
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "تمكين اتصال نظير إلى نظير عبر WebRTC"),
|
||||
("Enable TCP hole punching", "تمكين تقنية حفر الثغرات عبر TCP"),
|
||||
("The screen sharing request was declined on the remote device", "تم رفض طلب مشاركة الشاشة على الجهاز البعيد"),
|
||||
("No one responded to the screen sharing request on the remote device", "لم يستجب أحد لطلب مشاركة الشاشة على الجهاز البعيد"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "أنهى XDG Desktop Portal طلب مشاركة الشاشة ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "لم يُرجع XDG Desktop Portal أي شاشة لالتقاطها، قد تكون مكتبة PipeWire قديمة جدًا"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "مكوّن GStreamer الإضافي اللازم لالتقاط الشاشة مفقود ({})"),
|
||||
("The screen sharing request timed out on the remote device", "انتهت مهلة طلب مشاركة الشاشة على الجهاز البعيد"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "يتعذّر على RustDesk الوصول إلى جلسة سطح المكتب على الجهاز البعيد، تأكد من أن جلسة سطح المكتب تعمل وأن RustDesk يمكنه استخدامها"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "بوابة سطح المكتب على الجهاز البعيد تفتقر إلى إمكانية لازمة لمشاركة الشاشة أو التحكم عن بُعد، قد لا تكون واجهتها الخلفية مثبتة"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "تمت الموافقة على مشاركة الشاشة على الجهاز البعيد، لكن تعذّر فتح اتصال PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "انتهى طلب مشاركة الشاشة على الجهاز البعيد دون أن يكتمل"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "تعذّر على RustDesk الحصول على شاشة قابلة للاستخدام من XDG Desktop Portal، قد تكون مكتبة PipeWire قديمة جدًا"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "تعذّر على RustDesk تحميل مكوّن GStreamer اللازم لالتقاط الشاشة ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
774
src/lang/az.rs
Normal file
774
src/lang/az.rs
Normal file
@@ -0,0 +1,774 @@
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
[
|
||||
("Status", "Vəziyyət"),
|
||||
("Your Desktop", "Masaüstünüz"),
|
||||
("desk_tip", "Masaüstünüzə bu ID və parol ilə giriş etmək olar."),
|
||||
("Password", "Parol"),
|
||||
("Ready", "Hazır"),
|
||||
("Established", "Quruldu"),
|
||||
("connecting_status", "RustDesk şəbəkəsinə qoşulur..."),
|
||||
("Enable service", "Xidməti aktivləşdir"),
|
||||
("Start service", "Xidməti başlat"),
|
||||
("Service is running", "Xidmət işləyir"),
|
||||
("Service is not running", "Xidmət işləmir"),
|
||||
("not_ready_status", "Hazır deyil. Əlaqənizi yoxlayın"),
|
||||
("Control Remote Desktop", "Uzaq masaüstünü idarə et"),
|
||||
("Transfer file", "Fayl ötür"),
|
||||
("Connect", "Qoşul"),
|
||||
("Recent sessions", "Son sessiyalar"),
|
||||
("Address book", "Ünvan kitabı"),
|
||||
("Confirmation", "Təsdiq"),
|
||||
("TCP tunneling", "TCP tunelləmə"),
|
||||
("Remove", "Çıxar"),
|
||||
("Refresh random password", "Təsadüfi parolu yenilə"),
|
||||
("Set your own password", "Öz parolunuzu təyin edin"),
|
||||
("Enable keyboard/mouse", "Klaviaturanı/siçanı aktivləşdir"),
|
||||
("Enable clipboard", "Mübadilə buferini aktivləşdir"),
|
||||
("Enable file transfer", "Fayl ötürülməsini aktivləşdir"),
|
||||
("Enable TCP tunneling", "TCP tunelləməni aktivləşdir"),
|
||||
("IP Whitelisting", "IP ağ siyahısı"),
|
||||
("ID/Relay Server", "ID/Relay serveri"),
|
||||
("Import server config", "Server konfiqurasiyasını idxal et"),
|
||||
("Export Server Config", "Server konfiqurasiyasını ixrac et"),
|
||||
("Import server configuration successfully", "Server konfiqurasiyası uğurla idxal edildi"),
|
||||
("Export server configuration successfully", "Server konfiqurasiyası uğurla ixrac edildi"),
|
||||
("Invalid server configuration", "Yanlış server konfiqurasiyası"),
|
||||
("Clipboard is empty", "Mübadilə buferi boşdur"),
|
||||
("Stop service", "Xidməti dayandır"),
|
||||
("Change ID", "ID-ni dəyiş"),
|
||||
("Your new ID", "Yeni ID-niz"),
|
||||
("length %min% to %max%", "uzunluq %min% ilə %max% arasında"),
|
||||
("starts with a letter", "hərflə başlayır"),
|
||||
("allowed characters", "icazə verilən simvollar"),
|
||||
("id_change_tip", "Yalnız a-z, A-Z, 0-9, - (defis) və _ (alt xətt) simvollarına icazə verilir. İlk hərf a-z, A-Z olmalıdır. Uzunluq 6 ilə 16 arasında."),
|
||||
("Website", "Veb sayt"),
|
||||
("About", "Haqqında"),
|
||||
("Slogan_tip", "Bu qarışıq dünyada ürəklə hazırlanıb!"),
|
||||
("Privacy Statement", "Məxfilik bəyanatı"),
|
||||
("Mute", "Səssiz"),
|
||||
("Build Date", "Yığılma tarixi"),
|
||||
("Version", "Versiya"),
|
||||
("Home", "Əsas səhifə"),
|
||||
("Audio Input", "Audio girişi"),
|
||||
("Enhancements", "Təkmilləşdirmələr"),
|
||||
("Hardware Codec", "Aparat kodeki"),
|
||||
("Adaptive bitrate", "Adaptiv bitreyt"),
|
||||
("ID Server", "ID serveri"),
|
||||
("Relay Server", "Relay serveri"),
|
||||
("API Server", "API serveri"),
|
||||
("invalid_http", "http:// və ya https:// ilə başlamalıdır"),
|
||||
("Invalid IP", "Yanlış IP"),
|
||||
("Invalid format", "Yanlış format"),
|
||||
("server_not_support", "Server hələ dəstəkləmir"),
|
||||
("Not available", "Əlçatan deyil"),
|
||||
("Too frequent", "Çox tez-tez"),
|
||||
("Cancel", "Ləğv et"),
|
||||
("Skip", "Keç"),
|
||||
("Close", "Bağla"),
|
||||
("Retry", "Yenidən cəhd et"),
|
||||
("OK", "OK"),
|
||||
("Password Required", "Parol tələb olunur"),
|
||||
("Please enter your password", "Parolunuzu daxil edin"),
|
||||
("Remember password", "Parolu yadda saxla"),
|
||||
("Wrong Password", "Yanlış parol"),
|
||||
("Do you want to enter again?", "Yenidən daxil etmək istəyirsiniz?"),
|
||||
("Connection Error", "Əlaqə xətası"),
|
||||
("Error", "Xəta"),
|
||||
("Reset by the peer", "Qarşı tərəf əlaqəni sıfırladı"),
|
||||
("Connecting...", "Qoşulur..."),
|
||||
("Connection in progress. Please wait.", "Əlaqə qurulur. Gözləyin."),
|
||||
("Please try 1 minute later", "1 dəqiqə sonra cəhd edin"),
|
||||
("Login Error", "Giriş xətası"),
|
||||
("Successful", "Uğurlu"),
|
||||
("Connected, waiting for image...", "Qoşuldu, şəkil gözlənilir..."),
|
||||
("Name", "Ad"),
|
||||
("Type", "Növ"),
|
||||
("Modified", "Dəyişdirilib"),
|
||||
("Size", "Ölçü"),
|
||||
("Show Hidden Files", "Gizli faylları göstər"),
|
||||
("Receive", "Qəbul et"),
|
||||
("Send", "Göndər"),
|
||||
("Refresh File", "Faylı yenilə"),
|
||||
("Local", "Lokal"),
|
||||
("Remote", "Uzaq"),
|
||||
("Remote Computer", "Uzaq kompüter"),
|
||||
("Local Computer", "Lokal kompüter"),
|
||||
("Confirm Delete", "Silinməni təsdiqlə"),
|
||||
("Delete", "Sil"),
|
||||
("Properties", "Xüsusiyyətlər"),
|
||||
("Multi Select", "Çoxlu seçim"),
|
||||
("Select All", "Hamısını seç"),
|
||||
("Unselect All", "Seçimi ləğv et"),
|
||||
("Empty Directory", "Boş qovluq"),
|
||||
("Not an empty directory", "Qovluq boş deyil"),
|
||||
("Are you sure you want to delete this file?", "Bu faylı silmək istədiyinizə əminsiniz?"),
|
||||
("Are you sure you want to delete this empty directory?", "Bu boş qovluğu silmək istədiyinizə əminsiniz?"),
|
||||
("Are you sure you want to delete the file of this directory?", "Bu qovluğun faylını silmək istədiyinizə əminsiniz?"),
|
||||
("Do this for all conflicts", "Bunu bütün ziddiyyətlər üçün et"),
|
||||
("This is irreversible!", "Bu geri qaytarıla bilməz!"),
|
||||
("Deleting", "Silinir"),
|
||||
("files", "fayl"),
|
||||
("Waiting", "Gözlənilir"),
|
||||
("Finished", "Bitdi"),
|
||||
("Speed", "Sürət"),
|
||||
("Custom Image Quality", "Fərdi şəkil keyfiyyəti"),
|
||||
("Privacy mode", "Məxfilik rejimi"),
|
||||
("Block user input", "İstifadəçi girişini blokla"),
|
||||
("Unblock user input", "İstifadəçi girişinin blokunu aç"),
|
||||
("Adjust Window", "Pəncərəni uyğunlaşdır"),
|
||||
("Original", "Orijinal"),
|
||||
("Shrink", "Kiçilt"),
|
||||
("Stretch", "Uzat"),
|
||||
("Scrollbar", "Sürüşdürmə zolağı"),
|
||||
("ScrollAuto", "Avtomatik sürüşdürmə"),
|
||||
("Good image quality", "Yaxşı şəkil keyfiyyəti"),
|
||||
("Balanced", "Balanslı"),
|
||||
("Optimize reaction time", "Reaksiya vaxtını optimallaşdır"),
|
||||
("Custom", "Fərdi"),
|
||||
("Show remote cursor", "Uzaq kursoru göstər"),
|
||||
("Show quality monitor", "Keyfiyyət monitorunu göstər"),
|
||||
("Disable clipboard", "Mübadilə buferini söndür"),
|
||||
("Lock after session end", "Sessiya bitdikdən sonra kilidlə"),
|
||||
("Insert Ctrl + Alt + Del", "Ctrl + Alt + Del göndər"),
|
||||
("Insert Lock", "Kilid göndər"),
|
||||
("Refresh", "Yenilə"),
|
||||
("ID does not exist", "ID mövcud deyil"),
|
||||
("Failed to connect to rendezvous server", "Rendezvous serverinə qoşulmaq alınmadı"),
|
||||
("Please try later", "Sonra cəhd edin"),
|
||||
("Remote desktop is offline", "Uzaq masaüstü oflayndır"),
|
||||
("Key mismatch", "Açar uyğun gəlmir"),
|
||||
("Timeout", "Vaxt bitdi"),
|
||||
("Failed to connect to relay server", "Relay serverinə qoşulmaq alınmadı"),
|
||||
("Failed to connect via rendezvous server", "Rendezvous serveri vasitəsilə qoşulmaq alınmadı"),
|
||||
("Failed to connect via relay server", "Relay serveri vasitəsilə qoşulmaq alınmadı"),
|
||||
("Failed to make direct connection to remote desktop", "Uzaq masaüstünə birbaşa əlaqə qurmaq alınmadı"),
|
||||
("Set Password", "Parolu təyin et"),
|
||||
("OS Password", "Əməliyyat sistemi parolu"),
|
||||
("install_tip", "UAC səbəbindən RustDesk bəzi hallarda uzaq tərəf kimi düzgün işləyə bilmir. UAC-dan yayınmaq üçün aşağıdakı düyməyə basaraq RustDesk-i sistemə quraşdırın."),
|
||||
("Click to upgrade", "Yeniləmək üçün klikləyin"),
|
||||
("Configure", "Konfiqurasiya et"),
|
||||
("config_acc", "Masaüstünüzü uzaqdan idarə etmək üçün RustDesk-ə \"Əlçatanlıq\" icazələrini verməlisiniz."),
|
||||
("config_screen", "Masaüstünüzə uzaqdan giriş üçün RustDesk-ə \"Ekran Yazısı\" icazələrini verməlisiniz."),
|
||||
("Installing ...", "Quraşdırılır ..."),
|
||||
("Install", "Quraşdır"),
|
||||
("Installation", "Quraşdırma"),
|
||||
("Installation Path", "Quraşdırma yolu"),
|
||||
("Create start menu shortcuts", "Başlat menyusu qısayolları yarat"),
|
||||
("Create desktop icon", "Masaüstü ikonu yarat"),
|
||||
("agreement_tip", "Quraşdırmanı başlatmaqla lisenziya müqaviləsini qəbul edirsiniz."),
|
||||
("Accept and Install", "Qəbul et və quraşdır"),
|
||||
("End-user license agreement", "Son istifadəçi lisenziya müqaviləsi"),
|
||||
("Generating ...", "Yaradılır ..."),
|
||||
("Your installation is lower version.", "Quraşdırdığınız versiya köhnədir."),
|
||||
("not_close_tcp_tip", "Tuneldən istifadə edərkən bu pəncərəni bağlamayın"),
|
||||
("Listening ...", "Dinlənilir ..."),
|
||||
("Remote Host", "Uzaq host"),
|
||||
("Remote Port", "Uzaq port"),
|
||||
("Action", "Əməliyyat"),
|
||||
("Add", "Əlavə et"),
|
||||
("Local Port", "Lokal port"),
|
||||
("Local Address", "Lokal ünvan"),
|
||||
("Change Local Port", "Lokal portu dəyiş"),
|
||||
("setup_server_tip", "Daha sürətli əlaqə üçün öz serverinizi qurun"),
|
||||
("Too short, at least 6 characters.", "Çox qısadır, ən azı 6 simvol olmalıdır."),
|
||||
("The confirmation is not identical.", "Təsdiq eyni deyil."),
|
||||
("Permissions", "İcazələr"),
|
||||
("Accept", "Qəbul et"),
|
||||
("Dismiss", "İmtina et"),
|
||||
("Disconnect", "Əlaqəni kəs"),
|
||||
("Enable file copy and paste", "Fayl kopyalama və yapışdırmanı aktivləşdir"),
|
||||
("Connected", "Qoşuldu"),
|
||||
("Direct and encrypted connection", "Birbaşa və şifrələnmiş əlaqə"),
|
||||
("Relayed and encrypted connection", "Relay üzərindən şifrələnmiş əlaqə"),
|
||||
("Direct and unencrypted connection", "Birbaşa və şifrələnməmiş əlaqə"),
|
||||
("Relayed and unencrypted connection", "Relay üzərindən şifrələnməmiş əlaqə"),
|
||||
("Enter Remote ID", "Uzaq ID-ni daxil edin"),
|
||||
("Enter your password", "Parolunuzu daxil edin"),
|
||||
("Logging in...", "Giriş edilir..."),
|
||||
("Enable RDP session sharing", "RDP sessiya paylaşımını aktivləşdir"),
|
||||
("Auto Login", "Avtomatik giriş (yalnız \"Sessiya bitdikdən sonra kilidlə\" seçilibsə işləyir)"),
|
||||
("Enable direct IP access", "Birbaşa IP girişini aktivləşdir"),
|
||||
("Rename", "Adını dəyiş"),
|
||||
("Space", "Boşluq"),
|
||||
("Create desktop shortcut", "Masaüstü qısayolu yarat"),
|
||||
("Change Path", "Yolu dəyiş"),
|
||||
("Create Folder", "Qovluq yarat"),
|
||||
("Please enter the folder name", "Qovluğun adını daxil edin"),
|
||||
("Fix it", "Düzəlt"),
|
||||
("Warning", "Xəbərdarlıq"),
|
||||
("Login screen using Wayland is not supported", "Wayland ilə giriş ekranı dəstəklənmir"),
|
||||
("Reboot required", "Yenidən başlatma tələb olunur"),
|
||||
("Unsupported display server", "Dəstəklənməyən displey serveri"),
|
||||
("x11 expected", "x11 gözlənilir"),
|
||||
("Port", "Port"),
|
||||
("Settings", "Parametrlər"),
|
||||
("Username", "İstifadəçi adı"),
|
||||
("Invalid port", "Yanlış port"),
|
||||
("Closed manually by the peer", "Qarşı tərəf əl ilə bağladı"),
|
||||
("Enable remote configuration modification", "Uzaqdan konfiqurasiya dəyişikliyini aktivləşdir"),
|
||||
("Run without install", "Quraşdırmadan işə sal"),
|
||||
("Connect via relay", "Relay vasitəsilə qoşul"),
|
||||
("Always connect via relay", "Həmişə relay vasitəsilə qoşul"),
|
||||
("whitelist_tip", "Yalnız ağ siyahıdakı IP mənə giriş edə bilər"),
|
||||
("Login", "Giriş"),
|
||||
("Verify", "Doğrula"),
|
||||
("Remember me", "Məni xatırla"),
|
||||
("Trust this device", "Bu cihaza etibar et"),
|
||||
("Verification code", "Doğrulama kodu"),
|
||||
("verification_tip", "Qeydiyyatdan keçmiş e-poçt ünvanına doğrulama kodu göndərildi, girişi davam etdirmək üçün kodu daxil edin."),
|
||||
("Logout", "Çıxış"),
|
||||
("Tags", "Etiketlər"),
|
||||
("Search ID", "ID axtar"),
|
||||
("whitelist_sep", "Vergül, nöqtəli vergül, boşluq və ya yeni sətirlə ayrılır"),
|
||||
("Add ID", "ID əlavə et"),
|
||||
("Add Tag", "Etiket əlavə et"),
|
||||
("Unselect all tags", "Bütün etiketlərin seçimini ləğv et"),
|
||||
("Network error", "Şəbəkə xətası"),
|
||||
("Username missed", "İstifadəçi adı yazılmayıb"),
|
||||
("Password missed", "Parol yazılmayıb"),
|
||||
("Wrong credentials", "Yanlış istifadəçi adı və ya parol"),
|
||||
("The verification code is incorrect or has expired", "Doğrulama kodu yanlışdır və ya vaxtı bitib"),
|
||||
("Edit Tag", "Etiketi redaktə et"),
|
||||
("Forget Password", "Parolu unut"),
|
||||
("Favorites", "Seçilmişlər"),
|
||||
("Add to Favorites", "Seçilmişlərə əlavə et"),
|
||||
("Remove from Favorites", "Seçilmişlərdən çıxar"),
|
||||
("Empty", "Boş"),
|
||||
("Invalid folder name", "Yanlış qovluq adı"),
|
||||
("Socks5 Proxy", "Socks5 proksi"),
|
||||
("Socks5/Http(s) Proxy", "Socks5/Http(s) proksi"),
|
||||
("Discovered", "Aşkarlandı"),
|
||||
("install_daemon_tip", "Sistem açılışında başlaması üçün sistem xidmətini quraşdırmalısınız."),
|
||||
("Remote ID", "Uzaq ID"),
|
||||
("Paste", "Yapışdır"),
|
||||
("Paste here?", "Buraya yapışdırılsın?"),
|
||||
("Are you sure to close the connection?", "Əlaqəni bağlamaq istədiyinizə əminsiniz?"),
|
||||
("Download new version", "Yeni versiyanı endir"),
|
||||
("Touch mode", "Toxunuş rejimi"),
|
||||
("Mouse mode", "Siçan rejimi"),
|
||||
("One-Finger Tap", "Bir barmaqla toxunuş"),
|
||||
("Left Mouse", "Sol siçan düyməsi"),
|
||||
("One-Long Tap", "Bir barmaqla uzun toxunuş"),
|
||||
("Two-Finger Tap", "İki barmaqla toxunuş"),
|
||||
("Right Mouse", "Sağ siçan düyməsi"),
|
||||
("One-Finger Move", "Bir barmaqla hərəkət"),
|
||||
("Double Tap & Move", "İkiqat toxunuş və hərəkət"),
|
||||
("Mouse Drag", "Siçanla sürükləmə"),
|
||||
("Three-Finger vertically", "Üç barmaqla şaquli"),
|
||||
("Mouse Wheel", "Siçan çarxı"),
|
||||
("Two-Finger Move", "İki barmaqla hərəkət"),
|
||||
("Canvas Move", "Kətanın hərəkəti"),
|
||||
("Pinch to Zoom", "Barmaqlarla yaxınlaşdırma"),
|
||||
("Canvas Zoom", "Kətanın miqyası"),
|
||||
("Reset canvas", "Kətanı sıfırla"),
|
||||
("No permission of file transfer", "Fayl ötürülməsi üçün icazə yoxdur"),
|
||||
("Note", "Qeyd"),
|
||||
("Connection", "Əlaqə"),
|
||||
("Share screen", "Ekranı paylaş"),
|
||||
("Chat", "Söhbət"),
|
||||
("Total", "Ümumi"),
|
||||
("items", "element"),
|
||||
("Selected", "Seçilib"),
|
||||
("Screen Capture", "Ekran çəkilişi"),
|
||||
("Input Control", "Giriş idarəsi"),
|
||||
("Audio Capture", "Audio çəkilişi"),
|
||||
("Do you accept?", "Qəbul edirsiniz?"),
|
||||
("Open System Setting", "Sistem parametrini aç"),
|
||||
("How to get Android input permission?", "Android giriş icazəsi necə alınır?"),
|
||||
("android_input_permission_tip1", "Uzaq cihazın siçan və ya toxunuşla Android cihazınızı idarə etməsi üçün RustDesk-ə \"Əlçatanlıq\" xidmətindən istifadə icazəsi verməlisiniz."),
|
||||
("android_input_permission_tip2", "Növbəti sistem parametrləri səhifəsinə keçin, [Quraşdırılmış xidmətlər] bölməsini tapıb açın və [RustDesk Input] xidmətini işə salın."),
|
||||
("android_new_connection_tip", "Cari cihazınızı idarə etmək istəyən yeni idarəetmə sorğusu alındı."),
|
||||
("android_service_will_start_tip", "\"Ekran çəkilişi\"ni işə salmaq xidməti avtomatik başladacaq və digər cihazlara cihazınıza əlaqə sorğusu göndərməyə imkan verəcək."),
|
||||
("android_stop_service_tip", "Xidməti bağlamaq qurulmuş bütün əlaqələri avtomatik olaraq bağlayacaq."),
|
||||
("android_version_audio_tip", "Cari Android versiyası audio çəkilişini dəstəkləmir, Android 10 və ya daha yuxarı versiyaya yüksəldin."),
|
||||
("android_start_service_tip", "Ekran paylaşımı xidmətini başlatmaq üçün [Xidməti başlat] düyməsinə toxunun və ya [Ekran çəkilişi] icazəsini aktivləşdirin."),
|
||||
("android_permission_may_not_change_tip", "Qurulmuş əlaqələrin icazələri yenidən qoşulana qədər dərhal dəyişməyə bilər."),
|
||||
("Account", "Hesab"),
|
||||
("Overwrite", "Üzərinə yaz"),
|
||||
("This file exists, skip or overwrite this file?", "Bu fayl mövcuddur, keçilsin yoxsa üzərinə yazılsın?"),
|
||||
("Quit", "Çıx"),
|
||||
("Help", "Kömək"),
|
||||
("Failed", "Alınmadı"),
|
||||
("Succeeded", "Uğurlu oldu"),
|
||||
("Someone turns on privacy mode, exit", "Kimsə məxfilik rejimini açdı, çıxılır"),
|
||||
("Unsupported", "Dəstəklənmir"),
|
||||
("Peer denied", "Qarşı tərəf imtina etdi"),
|
||||
("Peer exit", "Qarşı tərəf çıxdı"),
|
||||
("Failed to turn off", "Söndürmək alınmadı"),
|
||||
("Turned off", "Söndürüldü"),
|
||||
("Language", "Dil"),
|
||||
("Keep RustDesk background service", "RustDesk fon xidmətini işlək saxla"),
|
||||
("Ignore Battery Optimizations", "Batareya optimallaşdırmalarını nəzərə alma"),
|
||||
("android_open_battery_optimizations_tip", "Bu funksiyanı söndürmək istəyirsinizsə, növbəti RustDesk tətbiq parametrləri səhifəsinə keçin, [Batareya] bölməsini tapıb açın və [Məhdudiyyətsiz] seçimini götürün"),
|
||||
("Start on boot", "Sistem açılışında başlat"),
|
||||
("Start the screen sharing service on boot, requires special permissions", "Ekran paylaşımı xidmətini sistem açılışında başlat, xüsusi icazələr tələb olunur"),
|
||||
("Connection not allowed", "Əlaqəyə icazə verilmir"),
|
||||
("Legacy mode", "Köhnə rejim"),
|
||||
("Map mode", "Xəritə rejimi"),
|
||||
("Translate mode", "Çevirmə rejimi"),
|
||||
("Use permanent password", "Daimi paroldan istifadə et"),
|
||||
("Use both passwords", "Hər iki paroldan istifadə et"),
|
||||
("Set permanent password", "Daimi parolu təyin et"),
|
||||
("Enable remote restart", "Uzaqdan yenidən başlatmanı aktivləşdir"),
|
||||
("Restart remote device", "Uzaq cihazı yenidən başlat"),
|
||||
("Are you sure you want to restart", "Yenidən başlatmaq istədiyinizə əminsiniz"),
|
||||
("Restarting remote device", "Uzaq cihaz yenidən başladılır"),
|
||||
("remote_restarting_tip", "Uzaq cihaz yenidən başladılır, bu mesaj qutusunu bağlayın və bir az sonra daimi parolla yenidən qoşulun"),
|
||||
("Copied", "Kopyalandı"),
|
||||
("Exit Fullscreen", "Tam ekrandan çıx"),
|
||||
("Fullscreen", "Tam ekran"),
|
||||
("Mobile Actions", "Mobil əməliyyatlar"),
|
||||
("Select Monitor", "Monitoru seç"),
|
||||
("Control Actions", "İdarəetmə əməliyyatları"),
|
||||
("Display Settings", "Ekran parametrləri"),
|
||||
("Ratio", "Nisbət"),
|
||||
("Image Quality", "Şəkil keyfiyyəti"),
|
||||
("Scroll Style", "Sürüşdürmə üslubu"),
|
||||
("Show Toolbar", "Alətlər panelini göstər"),
|
||||
("Hide Toolbar", "Alətlər panelini gizlət"),
|
||||
("Direct Connection", "Birbaşa əlaqə"),
|
||||
("Relay Connection", "Relay əlaqəsi"),
|
||||
("Secure Connection", "Təhlükəsiz əlaqə"),
|
||||
("Insecure Connection", "Təhlükəsiz olmayan əlaqə"),
|
||||
("Scale original", "Orijinal miqyas"),
|
||||
("Scale adaptive", "Uyğunlaşan miqyas"),
|
||||
("General", "Ümumi"),
|
||||
("Security", "Təhlükəsizlik"),
|
||||
("Theme", "Tema"),
|
||||
("Dark Theme", "Tünd tema"),
|
||||
("Light Theme", "Açıq tema"),
|
||||
("Dark", "Tünd"),
|
||||
("Light", "Açıq"),
|
||||
("Follow System", "Sistemə uyğun"),
|
||||
("Enable hardware codec", "Aparat kodekini aktivləşdir"),
|
||||
("Unlock Security Settings", "Təhlükəsizlik parametrlərinin kilidini aç"),
|
||||
("Enable audio", "Audionu aktivləşdir"),
|
||||
("Unlock Network Settings", "Şəbəkə parametrlərinin kilidini aç"),
|
||||
("Server", "Server"),
|
||||
("Direct IP Access", "Birbaşa IP girişi"),
|
||||
("Proxy", "Proksi"),
|
||||
("Apply", "Tətbiq et"),
|
||||
("Disconnect all devices?", "Bütün cihazlarla əlaqə kəsilsin?"),
|
||||
("Clear", "Təmizlə"),
|
||||
("Audio Input Device", "Audio giriş cihazı"),
|
||||
("Use IP Whitelisting", "IP ağ siyahısından istifadə et"),
|
||||
("Network", "Şəbəkə"),
|
||||
("Pin Toolbar", "Alətlər panelini sancaqla"),
|
||||
("Unpin Toolbar", "Alətlər panelinin sancağını çıxar"),
|
||||
("Recording", "Yazma"),
|
||||
("Directory", "Qovluq"),
|
||||
("Automatically record incoming sessions", "Gələn sessiyaları avtomatik yaz"),
|
||||
("Automatically record outgoing sessions", "Gedən sessiyaları avtomatik yaz"),
|
||||
("Change", "Dəyiş"),
|
||||
("Start session recording", "Sessiya yazısını başlat"),
|
||||
("Stop session recording", "Sessiya yazısını dayandır"),
|
||||
("Enable recording session", "Sessiya yazısını aktivləşdir"),
|
||||
("Enable LAN discovery", "LAN aşkarlanmasını aktivləşdir"),
|
||||
("Deny LAN discovery", "LAN aşkarlanmasına icazə vermə"),
|
||||
("Write a message", "Mesaj yazın"),
|
||||
("Prompt", "Sorğu"),
|
||||
("Please wait for confirmation of UAC...", "UAC təsdiqini gözləyin..."),
|
||||
("elevated_foreground_window_tip", "Uzaq masaüstünün cari pəncərəsi işləmək üçün daha yüksək səlahiyyət tələb edir, ona görə siçan və klaviaturadan müvəqqəti istifadə etmək mümkün deyil. Uzaq istifadəçidən cari pəncərəni kiçiltməsini xahiş edə və ya əlaqə idarəetmə pəncərəsindəki səlahiyyət yüksəltmə düyməsini basa bilərsiniz. Bu problemin qarşısını almaq üçün proqramı uzaq cihaza quraşdırmaq tövsiyə olunur."),
|
||||
("Disconnected", "Əlaqə kəsildi"),
|
||||
("Other", "Digər"),
|
||||
("Confirm before closing multiple tabs", "Çoxlu tabı bağlamazdan əvvəl təsdiq soruş"),
|
||||
("Keyboard Settings", "Klaviatura parametrləri"),
|
||||
("Full Access", "Tam giriş"),
|
||||
("Screen Share", "Ekran paylaşımı"),
|
||||
("ubuntu-21-04-required", "Wayland Ubuntu 21.04 və ya daha yuxarı versiya tələb edir."),
|
||||
("wayland-requires-higher-linux-version", "Wayland daha yuxarı Linux distributiv versiyası tələb edir. X11 masaüstünü sınayın və ya əməliyyat sisteminizi dəyişin."),
|
||||
("xdp-portal-unavailable", "Wayland ekran çəkilişi alınmadı. XDG Desktop Portal çökmüş və ya əlçatmaz ola bilər. `systemctl --user restart xdg-desktop-portal` ilə yenidən başlatmağı sınayın."),
|
||||
("JumpLink", "Bax"),
|
||||
("Please Select the screen to be shared(Operate on the peer side).", "Paylaşılacaq ekranı seçin(Qarşı tərəfdə edilir)."),
|
||||
("Show RustDesk", "RustDesk-i göstər"),
|
||||
("This PC", "Bu kompüter"),
|
||||
("or", "və ya"),
|
||||
("Elevate", "Səlahiyyəti yüksəlt"),
|
||||
("Zoom cursor", "Kursoru böyüt"),
|
||||
("Accept sessions via password", "Sessiyaları parolla qəbul et"),
|
||||
("Accept sessions via click", "Sessiyaları kliklə qəbul et"),
|
||||
("Accept sessions via both", "Sessiyaları hər ikisi ilə qəbul et"),
|
||||
("Please wait for the remote side to accept your session request...", "Uzaq tərəfin sessiya sorğunuzu qəbul etməsini gözləyin..."),
|
||||
("One-time Password", "Birdəfəlik parol"),
|
||||
("Use one-time password", "Birdəfəlik paroldan istifadə et"),
|
||||
("One-time password length", "Birdəfəlik parolun uzunluğu"),
|
||||
("Request access to your device", "Cihazınıza giriş sorğusu"),
|
||||
("Hide connection management window", "Əlaqə idarəetmə pəncərəsini gizlət"),
|
||||
("hide_cm_tip", "Gizlətməyə yalnız sessiyalar parolla qəbul edilirsə və daimi paroldan istifadə olunursa icazə verilir"),
|
||||
("wayland_experiment_tip", "Wayland dəstəyi eksperimental mərhələdədir, nəzarətsiz giriş lazımdırsa X11 işlədin."),
|
||||
("Right click to select tabs", "Tabları seçmək üçün sağ klikləyin"),
|
||||
("Skipped", "Keçildi"),
|
||||
("Add to address book", "Ünvan kitabına əlavə et"),
|
||||
("Group", "Qrup"),
|
||||
("Search", "Axtarış"),
|
||||
("Closed manually by web console", "Veb konsoldan əl ilə bağlandı"),
|
||||
("Local keyboard type", "Lokal klaviatura növü"),
|
||||
("Select local keyboard type", "Lokal klaviatura növünü seçin"),
|
||||
("software_render_tip", "Linux-da Nvidia video kartından istifadə edirsinizsə və qoşulduqdan dərhal sonra uzaq pəncərə bağlanırsa, açıq mənbəli Nouveau sürücüsünə keçmək və proqram təminatı ilə render seçmək kömək edə bilər. Proqramın yenidən başladılması tələb olunur."),
|
||||
("Always use software rendering", "Həmişə proqram təminatı ilə render işlət"),
|
||||
("config_input", "Uzaq masaüstünü klaviatura ilə idarə etmək üçün RustDesk-ə \"Giriş Monitorinqi\" icazələrini verməlisiniz."),
|
||||
("config_microphone", "Uzaqdan danışmaq üçün RustDesk-ə \"Audio Yazısı\" icazələrini verməlisiniz."),
|
||||
("request_elevation_tip", "Uzaq tərəfdə kimsə varsa, səlahiyyət yüksəltmə də tələb edə bilərsiniz."),
|
||||
("Wait", "Gözlə"),
|
||||
("Elevation Error", "Səlahiyyət yüksəltmə xətası"),
|
||||
("Ask the remote user for authentication", "Uzaq istifadəçidən doğrulama istə"),
|
||||
("Choose this if the remote account is administrator", "Uzaq hesab administratordursa bunu seçin"),
|
||||
("Transmit the username and password of administrator", "Administratorun istifadəçi adını və parolunu ötür"),
|
||||
("still_click_uac_tip", "Yenə də uzaq istifadəçinin işləyən RustDesk-in UAC pəncərəsində OK düyməsini basması tələb olunur."),
|
||||
("Request Elevation", "Səlahiyyət yüksəltmə tələb et"),
|
||||
("wait_accept_uac_tip", "Uzaq istifadəçinin UAC dialoqunu qəbul etməsini gözləyin."),
|
||||
("Elevate successfully", "Səlahiyyət uğurla yüksəldildi"),
|
||||
("uppercase", "böyük hərf"),
|
||||
("lowercase", "kiçik hərf"),
|
||||
("digit", "rəqəm"),
|
||||
("special character", "xüsusi simvol"),
|
||||
("length>=8", "uzunluq>=8"),
|
||||
("Weak", "Zəif"),
|
||||
("Medium", "Orta"),
|
||||
("Strong", "Güclü"),
|
||||
("Switch Sides", "Tərəfləri dəyiş"),
|
||||
("Please confirm if you want to share your desktop?", "Masaüstünüzü paylaşmaq istədiyinizi təsdiqləyin?"),
|
||||
("Display", "Ekran"),
|
||||
("Default View Style", "Standart baxış üslubu"),
|
||||
("Default Scroll Style", "Standart sürüşdürmə üslubu"),
|
||||
("Default Image Quality", "Standart şəkil keyfiyyəti"),
|
||||
("Default Codec", "Standart kodek"),
|
||||
("Bitrate", "Bitreyt"),
|
||||
("FPS", "FPS"),
|
||||
("Auto", "Avtomatik"),
|
||||
("Other Default Options", "Digər standart seçimlər"),
|
||||
("Voice call", "Səsli zəng"),
|
||||
("Text chat", "Mətn söhbəti"),
|
||||
("Stop voice call", "Səsli zəngi dayandır"),
|
||||
("relay_hint_tip", "Birbaşa qoşulmaq mümkün olmaya bilər; relay vasitəsilə qoşulmağı sınaya bilərsiniz. Bundan başqa, ilk cəhddə relay işlətmək istəyirsinizsə, ID-yə \"/r\" şəkilçisi əlavə edin və ya son sessiyalar kartında varsa \"Həmişə relay vasitəsilə qoşul\" seçimini işarələyin."),
|
||||
("Reconnect", "Yenidən qoşul"),
|
||||
("Codec", "Kodek"),
|
||||
("Resolution", "Ayırdetmə"),
|
||||
("No transfers in progress", "Davam edən ötürülmə yoxdur"),
|
||||
("Set one-time password length", "Birdəfəlik parolun uzunluğunu təyin et"),
|
||||
("RDP Settings", "RDP parametrləri"),
|
||||
("Sort by", "Sıralama"),
|
||||
("New Connection", "Yeni əlaqə"),
|
||||
("Restore", "Bərpa et"),
|
||||
("Minimize", "Kiçilt"),
|
||||
("Maximize", "Böyüt"),
|
||||
("Your Device", "Cihazınız"),
|
||||
("empty_recent_tip", "Təəssüf, son sessiya yoxdur!\nYenisini planlaşdırmaq vaxtıdır."),
|
||||
("empty_favorite_tip", "Hələ seçilmiş cihaz yoxdur?\nGəlin qoşulacaq birini tapıb seçilmişlərə əlavə edək!"),
|
||||
("empty_lan_tip", "Görünür, hələ heç bir cihaz aşkarlanmayıb."),
|
||||
("empty_address_book_tip", "Görünür, ünvan kitabınızda hazırda heç bir cihaz yoxdur."),
|
||||
("Empty Username", "Boş istifadəçi adı"),
|
||||
("Empty Password", "Boş parol"),
|
||||
("Me", "Mən"),
|
||||
("identical_file_tip", "Bu fayl qarşı tərəfdəki ilə eynidir."),
|
||||
("show_monitors_tip", "Monitorları alətlər panelində göstər"),
|
||||
("View Mode", "Baxış rejimi"),
|
||||
("verify_rustdesk_password_tip", "RustDesk parolunu doğrula"),
|
||||
("No need to elevate", "Səlahiyyəti yüksəltməyə ehtiyac yoxdur"),
|
||||
("System Sound", "Sistem səsi"),
|
||||
("Default", "Standart"),
|
||||
("New RDP", "Yeni RDP"),
|
||||
("Fingerprint", "Barmaq izi"),
|
||||
("Copy Fingerprint", "Barmaq izini kopyala"),
|
||||
("no fingerprints", "Barmaq izi yoxdur"),
|
||||
("Update", "Yenilə"),
|
||||
("resolution_original_tip", "Orijinal ayırdetmə"),
|
||||
("resolution_fit_local_tip", "Lokal ayırdetməyə uyğunlaşdır"),
|
||||
("resolution_custom_tip", "Fərdi ayırdetmə"),
|
||||
("Collapse toolbar", "Alətlər panelini yığ"),
|
||||
("Accept and Elevate", "Qəbul et və səlahiyyəti yüksəlt"),
|
||||
("accept_and_elevate_btn_tooltip", "Əlaqəni qəbul et və UAC icazələrini yüksəlt."),
|
||||
("clipboard_wait_response_timeout_tip", "Kopyalama cavabı gözlənilərkən vaxt bitdi."),
|
||||
("Incoming connection", "Gələn əlaqə"),
|
||||
("Outgoing connection", "Gedən əlaqə"),
|
||||
("Exit", "Çıx"),
|
||||
("Open", "Aç"),
|
||||
("logout_tip", "Çıxmaq istədiyinizə əminsiniz?"),
|
||||
("Service", "Xidmət"),
|
||||
("Start", "Başlat"),
|
||||
("Stop", "Dayandır"),
|
||||
("exceed_max_devices", "İdarə olunan cihazların maksimum sayına çatmısınız."),
|
||||
("Sync with recent sessions", "Son sessiyalarla sinxronlaşdır"),
|
||||
("Sort tags", "Etiketləri sırala"),
|
||||
("Open connection in new tab", "Əlaqəni yeni tabda aç"),
|
||||
("Move tab to new window", "Tabı yeni pəncərəyə köçür"),
|
||||
("Can not be empty", "Boş ola bilməz"),
|
||||
("Already exists", "Artıq mövcuddur"),
|
||||
("Change Password", "Parolu dəyiş"),
|
||||
("Refresh Password", "Parolu yenilə"),
|
||||
("ID", "ID"),
|
||||
("Grid View", "Tor görünüşü"),
|
||||
("List View", "Siyahı görünüşü"),
|
||||
("Select", "Seç"),
|
||||
("Toggle Tags", "Etiketləri aç/bağla"),
|
||||
("pull_ab_failed_tip", "Ünvan kitabını yeniləmək alınmadı"),
|
||||
("push_ab_failed_tip", "Ünvan kitabını serverlə sinxronlaşdırmaq alınmadı"),
|
||||
("synced_peer_readded_tip", "Son sessiyalarda olan cihazlar ünvan kitabına geri sinxronlaşdırılacaq."),
|
||||
("Change Color", "Rəngi dəyiş"),
|
||||
("Primary Color", "Əsas rəng"),
|
||||
("HSV Color", "HSV rəngi"),
|
||||
("Installation Successful!", "Quraşdırma uğurlu oldu!"),
|
||||
("Installation failed!", "Quraşdırma alınmadı!"),
|
||||
("Reverse mouse wheel", "Siçan çarxının istiqamətini tərsinə çevir"),
|
||||
("{} sessions", "{} sessiya"),
|
||||
("scam_title", "SİZİ ALDADA BİLƏRLƏR!"),
|
||||
("scam_text1", "Tanımadığınız və ETİBAR ETMƏDİYİNİZ biri telefonda sizdən RustDesk işlətməyi və xidməti başlatmağı xahiş edirsə, davam etməyin və dərhal telefonu bağlayın."),
|
||||
("scam_text2", "Böyük ehtimalla o, pulunuzu və ya digər şəxsi məlumatlarınızı oğurlamağa çalışan fırıldaqçıdır."),
|
||||
("Don't show again", "Bir daha göstərmə"),
|
||||
("I Agree", "Razıyam"),
|
||||
("Decline", "Rədd et"),
|
||||
("Timeout in minutes", "Dəqiqə ilə gözləmə müddəti"),
|
||||
("auto_disconnect_option_tip", "İstifadəçi fəaliyyət göstərmədikdə gələn sessiyaları avtomatik bağla"),
|
||||
("Connection failed due to inactivity", "Fəaliyyətsizliyə görə əlaqə avtomatik kəsildi"),
|
||||
("Check for software update on startup", "Başlanğıcda proqram yeniləməsini yoxla"),
|
||||
("upgrade_rustdesk_server_pro_to_{}_tip", "RustDesk Server Pro-nu {} və ya daha yeni versiyaya yüksəldin!"),
|
||||
("pull_group_failed_tip", "Qrupu yeniləmək alınmadı"),
|
||||
("Filter by intersection", "Kəsişməyə görə süz"),
|
||||
("Remove wallpaper during incoming sessions", "Gələn sessiyalar zamanı divar kağızını götür"),
|
||||
("Test", "Sına"),
|
||||
("display_is_plugged_out_msg", "Ekran ayrıldı, birinci ekrana keçilir."),
|
||||
("No displays", "Ekran yoxdur"),
|
||||
("Open in new window", "Yeni pəncərədə aç"),
|
||||
("Show displays as individual windows", "Ekranları ayrı pəncərələr kimi göstər"),
|
||||
("Use all my displays for the remote session", "Uzaq sessiya üçün bütün ekranlarımı işlət"),
|
||||
("selinux_tip", "Cihazınızda SELinux aktivdir, bu, RustDesk-in idarə olunan tərəf kimi düzgün işləməsinə mane ola bilər."),
|
||||
("Change view", "Görünüşü dəyiş"),
|
||||
("Big tiles", "Böyük xanalar"),
|
||||
("Small tiles", "Kiçik xanalar"),
|
||||
("List", "Siyahı"),
|
||||
("Virtual display", "Virtual ekran"),
|
||||
("Plug out all", "Hamısını ayır"),
|
||||
("True color (4:4:4)", "Tam rəng (4:4:4)"),
|
||||
("Enable blocking user input", "İstifadəçi girişinin bloklanmasını aktivləşdir"),
|
||||
("id_input_tip", "ID, birbaşa IP və ya portla birlikdə domen (<domain>:<port>) daxil edə bilərsiniz.\nBaşqa serverdəki cihaza giriş etmək istəyirsinizsə, server ünvanını əlavə edin (<id>@<server_address>?key=<key_value>), məsələn,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nİctimai serverdəki cihaza giriş etmək istəyirsinizsə, \"<id>@public\" daxil edin, ictimai server üçün açar lazım deyil.\n\nİlk əlaqədə relay istifadəsini məcbur etmək istəyirsinizsə, ID-nin sonuna \"/r\" əlavə edin, məsələn, \"9123456234/r\"."),
|
||||
("privacy_mode_impl_mag_tip", "Rejim 1"),
|
||||
("privacy_mode_impl_virtual_display_tip", "Rejim 2"),
|
||||
("Enter privacy mode", "Məxfilik rejiminə keç"),
|
||||
("Exit privacy mode", "Məxfilik rejimindən çıx"),
|
||||
("idd_not_support_under_win10_2004_tip", "Dolayı ekran sürücüsü dəstəklənmir. Windows 10, versiya 2004 və ya daha yenisi tələb olunur."),
|
||||
("input_source_1_tip", "Giriş mənbəyi 1"),
|
||||
("input_source_2_tip", "Giriş mənbəyi 2"),
|
||||
("Swap control-command key", "Control və command düymələrini dəyişdir"),
|
||||
("swap-left-right-mouse", "Sol və sağ siçan düymələrini dəyişdir"),
|
||||
("2FA code", "2FA kodu"),
|
||||
("More", "Daha çox"),
|
||||
("enable-2fa-title", "İkifaktorlu doğrulamanı aktivləşdir"),
|
||||
("enable-2fa-desc", "Doğrulayıcınızı indi qurun. Telefonunuzda və ya kompüterinizdə Authy, Microsoft və ya Google Authenticator kimi doğrulayıcı tətbiqdən istifadə edə bilərsiniz.\n\nQR kodu tətbiqinizlə skan edin və ikifaktorlu doğrulamanı aktivləşdirmək üçün tətbiqin göstərdiyi kodu daxil edin."),
|
||||
("wrong-2fa-code", "Kod doğrulana bilmir. Kodun və lokal vaxt parametrlərinin düzgün olduğunu yoxlayın"),
|
||||
("enter-2fa-title", "İkifaktorlu doğrulama"),
|
||||
("Email verification code must be 6 characters.", "E-poçt doğrulama kodu 6 simvol olmalıdır."),
|
||||
("2FA code must be 6 digits.", "2FA kodu 6 rəqəm olmalıdır."),
|
||||
("Multiple Windows sessions found", "Bir neçə Windows sessiyası tapıldı"),
|
||||
("Please select the session you want to connect to", "Qoşulmaq istədiyiniz sessiyanı seçin"),
|
||||
("powered_by_me", "RustDesk ilə işləyir"),
|
||||
("outgoing_only_desk_tip", "Bu, fərdiləşdirilmiş buraxılışdır.\nSiz digər cihazlara qoşula bilərsiniz, lakin digər cihazlar sizin cihazınıza qoşula bilməz."),
|
||||
("preset_password_warning", "Bu fərdiləşdirilmiş buraxılış öncədən təyin edilmiş parolla gəlir. Bu parolu bilən hər kəs cihazınıza tam nəzarət edə bilər. Bunu gözləmirdinizsə, proqramı dərhal silin."),
|
||||
("Security Alert", "Təhlükəsizlik xəbərdarlığı"),
|
||||
("My address book", "Ünvan kitabım"),
|
||||
("Personal", "Şəxsi"),
|
||||
("Owner", "Sahib"),
|
||||
("Set shared password", "Paylaşılan parolu təyin et"),
|
||||
("Exist in", "Mövcuddur"),
|
||||
("Read-only", "Yalnız oxu"),
|
||||
("Read/Write", "Oxu/Yaz"),
|
||||
("Full Control", "Tam nəzarət"),
|
||||
("share_warning_tip", "Yuxarıdakı sahələr paylaşılır və başqaları tərəfindən görünür."),
|
||||
("Everyone", "Hər kəs"),
|
||||
("ab_web_console_tip", "Ətraflı məlumat veb konsolda"),
|
||||
("allow-only-conn-window-open-tip", "Əlaqəyə yalnız RustDesk pəncərəsi açıq olduqda icazə ver"),
|
||||
("no_need_privacy_mode_no_physical_displays_tip", "Fiziki ekran yoxdur, məxfilik rejiminə ehtiyac yoxdur."),
|
||||
("Follow remote cursor", "Uzaq kursoru izlə"),
|
||||
("Follow remote window focus", "Uzaq pəncərənin fokusunu izlə"),
|
||||
("default_proxy_tip", "Standart protokol və port Socks5 və 1080-dir"),
|
||||
("no_audio_input_device_tip", "Audio giriş cihazı tapılmadı."),
|
||||
("Incoming", "Gələn"),
|
||||
("Outgoing", "Gedən"),
|
||||
("Clear Wayland screen selection", "Wayland ekran seçimini təmizlə"),
|
||||
("clear_Wayland_screen_selection_tip", "Ekran seçimini təmizlədikdən sonra paylaşılacaq ekranı yenidən seçə bilərsiniz."),
|
||||
("confirm_clear_Wayland_screen_selection_tip", "Wayland ekran seçimini təmizləmək istədiyinizə əminsiniz?"),
|
||||
("android_new_voice_call_tip", "Yeni səsli zəng sorğusu alındı. Qəbul etsəniz, audio səsli ünsiyyətə keçəcək."),
|
||||
("texture_render_tip", "Şəkillərin daha hamar olması üçün tekstura renderindən istifadə edin. Render problemləri ilə qarşılaşsanız bu seçimi söndürməyi sınaya bilərsiniz."),
|
||||
("Use texture rendering", "Tekstura renderindən istifadə et"),
|
||||
("Floating window", "Üzən pəncərə"),
|
||||
("floating_window_tip", "RustDesk fon xidmətini işlək saxlamağa kömək edir"),
|
||||
("Keep screen on", "Ekranı açıq saxla"),
|
||||
("Never", "Heç vaxt"),
|
||||
("During controlled", "İdarə olunarkən"),
|
||||
("During service is on", "Xidmət işləyərkən"),
|
||||
("Capture screen using DirectX", "Ekranı DirectX ilə çək"),
|
||||
("Back", "Geri"),
|
||||
("Apps", "Tətbiqlər"),
|
||||
("Volume up", "Səsi artır"),
|
||||
("Volume down", "Səsi azalt"),
|
||||
("Power", "Güc"),
|
||||
("Telegram bot", "Telegram botu"),
|
||||
("enable-bot-tip", "Bu funksiyanı aktivləşdirsəniz, 2FA kodunu botunuzdan ala bilərsiniz. O, həm də əlaqə bildirişi kimi işləyə bilər."),
|
||||
("enable-bot-desc", "1. @BotFather ilə söhbət açın.\n2. \"/newbot\" əmrini göndərin. Bu addımı tamamladıqdan sonra token alacaqsınız.\n3. Yeni yaratdığınız botla söhbətə başlayın. Onu aktivləşdirmək üçün \"/hello\" kimi kəsik xətt (\"/\") ilə başlayan mesaj göndərin.\n"),
|
||||
("cancel-2fa-confirm-tip", "2FA-nı ləğv etmək istədiyinizə əminsiniz?"),
|
||||
("cancel-bot-confirm-tip", "Telegram botunu ləğv etmək istədiyinizə əminsiniz?"),
|
||||
("About RustDesk", "RustDesk haqqında"),
|
||||
("Send clipboard keystrokes", "Mübadilə buferi düymə vurmalarını göndər"),
|
||||
("network_error_tip", "Şəbəkə əlaqənizi yoxlayın, sonra yenidən cəhd düyməsini basın."),
|
||||
("Unlock with PIN", "PIN ilə kilidi aç"),
|
||||
("Requires at least {} characters", "Ən azı {} simvol tələb olunur"),
|
||||
("Wrong PIN", "Yanlış PIN"),
|
||||
("Set PIN", "PIN təyin et"),
|
||||
("Enable trusted devices", "Etibarlı cihazları aktivləşdir"),
|
||||
("Manage trusted devices", "Etibarlı cihazları idarə et"),
|
||||
("Platform", "Platforma"),
|
||||
("Days remaining", "Qalan günlər"),
|
||||
("enable-trusted-devices-tip", "Etibarlı cihazlarda 2FA doğrulamasını keç"),
|
||||
("Parent directory", "Yuxarı qovluq"),
|
||||
("Resume", "Davam et"),
|
||||
("Invalid file name", "Yanlış fayl adı"),
|
||||
("one-way-file-transfer-tip", "İdarə olunan tərəfdə birtərəfli fayl ötürülməsi aktivdir."),
|
||||
("Authentication Required", "Doğrulama tələb olunur"),
|
||||
("Authenticate", "Doğrula"),
|
||||
("web_id_input_tip", "Eyni serverdəki ID-ni daxil edə bilərsiniz, veb klientdə birbaşa IP girişi dəstəklənmir.\nBaşqa serverdəki cihaza giriş etmək istəyirsinizsə, server ünvanını əlavə edin (<id>@<server_address>?key=<key_value>), məsələn,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nİctimai serverdəki cihaza giriş etmək istəyirsinizsə, \"<id>@public\" daxil edin, ictimai server üçün açar lazım deyil."),
|
||||
("Download", "Endir"),
|
||||
("Upload folder", "Qovluq yüklə"),
|
||||
("Upload files", "Fayl yüklə"),
|
||||
("Clipboard is synchronized", "Mübadilə buferi sinxronlaşdırılıb"),
|
||||
("Update client clipboard", "Klientin mübadilə buferini yenilə"),
|
||||
("Untagged", "Etiketsiz"),
|
||||
("new-version-of-{}-tip", "{} proqramının yeni versiyası mövcuddur"),
|
||||
("Accessible devices", "Əlçatan cihazlar"),
|
||||
("upgrade_remote_rustdesk_client_to_{}_tip", "Uzaq tərəfdə RustDesk klientini {} və ya daha yeni versiyaya yüksəldin!"),
|
||||
("d3d_render_tip", "D3D render aktiv olduqda bəzi kompüterlərdə uzaq idarəetmə ekranı qara ola bilər."),
|
||||
("Use D3D rendering", "D3D renderindən istifadə et"),
|
||||
("Printer", "Printer"),
|
||||
("printer-os-requirement-tip", "Gedən çap funksiyası Windows 10 və ya daha yuxarı versiya tələb edir."),
|
||||
("printer-requires-installed-{}-client-tip", "Uzaqdan çapdan istifadə etmək üçün bu cihazda {} quraşdırılmalıdır."),
|
||||
("printer-{}-not-installed-tip", "{} Printeri quraşdırılmayıb."),
|
||||
("printer-{}-ready-tip", "{} Printeri quraşdırılıb və istifadəyə hazırdır."),
|
||||
("Install {} Printer", "{} Printerini quraşdır"),
|
||||
("Outgoing Print Jobs", "Gedən çap tapşırıqları"),
|
||||
("Incoming Print Jobs", "Gələn çap tapşırıqları"),
|
||||
("Incoming Print Job", "Gələn çap tapşırığı"),
|
||||
("use-the-default-printer-tip", "Standart printerdən istifadə et"),
|
||||
("use-the-selected-printer-tip", "Seçilmiş printerdən istifadə et"),
|
||||
("auto-print-tip", "Seçilmiş printerlə avtomatik çap et."),
|
||||
("print-incoming-job-confirm-tip", "Uzaq tərəfdən çap tapşırığı aldınız. Onu öz tərəfinizdə icra etmək istəyirsiniz?"),
|
||||
("remote-printing-disallowed-tile-tip", "Uzaqdan çapa icazə verilmir"),
|
||||
("remote-printing-disallowed-text-tip", "İdarə olunan tərəfin icazə parametrləri uzaqdan çapı qadağan edir."),
|
||||
("save-settings-tip", "Parametrləri yadda saxla"),
|
||||
("dont-show-again-tip", "Bunu bir daha göstərmə"),
|
||||
("Take screenshot", "Ekran görüntüsü al"),
|
||||
("Taking screenshot", "Ekran görüntüsü alınır"),
|
||||
("screenshot-merged-screen-not-supported-tip", "Bir neçə ekranın görüntüsünü birləşdirmək hazırda dəstəklənmir. Tək ekrana keçib yenidən cəhd edin."),
|
||||
("screenshot-action-tip", "Ekran görüntüsü ilə necə davam edəcəyinizi seçin."),
|
||||
("Save as", "Fərqli yadda saxla"),
|
||||
("Export", "İxrac et"),
|
||||
("Export Logs", "Jurnalları ixrac et"),
|
||||
("Import Folder", "Qovluq idxal et"),
|
||||
("Copy to clipboard", "Mübadilə buferinə kopyala"),
|
||||
("Enable remote printer", "Uzaq printeri aktivləşdir"),
|
||||
("Downloading {}", "{} endirilir"),
|
||||
("{} Update", "{} yeniləməsi"),
|
||||
("{}-to-update-tip", "{} indi bağlanacaq və yeni versiyanı quraşdıracaq."),
|
||||
("download-new-version-failed-tip", "Endirmə alınmadı. Yenidən cəhd edə və ya \"Endir\" düyməsini basıb buraxılış səhifəsindən endirərək əl ilə yeniləyə bilərsiniz."),
|
||||
("Auto update", "Avtomatik yeniləmə"),
|
||||
("update-failed-check-msi-tip", "Quraşdırma üsulunun yoxlanışı alınmadı. \"Endir\" düyməsini basıb buraxılış səhifəsindən endirin və əl ilə yeniləyin."),
|
||||
("websocket_tip", "WebSocket işlədilərkən yalnız relay əlaqələri dəstəklənir."),
|
||||
("Use WebSocket", "WebSocket işlət"),
|
||||
("Trackpad speed", "Trekped sürəti"),
|
||||
("Default trackpad speed", "Standart trekped sürəti"),
|
||||
("Numeric one-time password", "Rəqəmli birdəfəlik parol"),
|
||||
("Enable IPv6 P2P connection", "IPv6 P2P əlaqəsini aktivləşdir"),
|
||||
("Enable UDP hole punching", "UDP deşik açmanı aktivləşdir"),
|
||||
("View camera", "Kameraya bax"),
|
||||
("Enable camera", "Kameranı aktivləşdir"),
|
||||
("No cameras", "Kamera yoxdur"),
|
||||
("view_camera_unsupported_tip", "Uzaq cihaz kameraya baxışı dəstəkləmir."),
|
||||
("Terminal", "Terminal"),
|
||||
("Enable terminal", "Terminalı aktivləşdir"),
|
||||
("New tab", "Yeni tab"),
|
||||
("Keep terminal sessions on disconnect", "Əlaqə kəsiləndə terminal sessiyalarını saxla"),
|
||||
("Terminal (Run as administrator)", "Terminal (Administrator kimi işə sal)"),
|
||||
("terminal-admin-login-tip", "İdarə olunan tərəfin administrator istifadəçi adını və parolunu daxil edin."),
|
||||
("Failed to get user token.", "İstifadəçi tokenini almaq alınmadı."),
|
||||
("Incorrect username or password.", "Yanlış istifadəçi adı və ya parol."),
|
||||
("The user is not an administrator.", "İstifadəçi administrator deyil."),
|
||||
("Failed to check if the user is an administrator.", "İstifadəçinin administrator olduğunu yoxlamaq alınmadı."),
|
||||
("Supported only in the installed version.", "Yalnız quraşdırılmış versiyada dəstəklənir."),
|
||||
("elevation_username_tip", "İstifadəçi adını və ya domen\\istifadəçi_adı daxil edin"),
|
||||
("Preparing for installation ...", "Quraşdırmaya hazırlanır ..."),
|
||||
("Show my cursor", "Öz kursorumu göstər"),
|
||||
("Scale custom", "Fərdi miqyas"),
|
||||
("Custom scale slider", "Fərdi miqyas sürüşdürücüsü"),
|
||||
("Decrease", "Azalt"),
|
||||
("Increase", "Artır"),
|
||||
("Show virtual mouse", "Virtual siçanı göstər"),
|
||||
("Virtual mouse size", "Virtual siçanın ölçüsü"),
|
||||
("Small", "Kiçik"),
|
||||
("Large", "Böyük"),
|
||||
("Show virtual joystick", "Virtual coystiki göstər"),
|
||||
("Edit note", "Qeydi redaktə et"),
|
||||
("Alias", "Ləqəb"),
|
||||
("ScrollEdge", "Kənardan sürüşdürmə"),
|
||||
("Allow insecure TLS fallback", "Təhlükəsiz olmayan TLS ehtiyat rejiminə icazə ver"),
|
||||
("allow-insecure-tls-fallback-tip", "Standart olaraq RustDesk TLS işlədən protokollar üçün server sertifikatını yoxlayır.\nBu seçim aktiv olduqda, yoxlama alınmadığı halda RustDesk yoxlama addımını keçib davam edəcək."),
|
||||
("Disable UDP", "UDP-ni söndür"),
|
||||
("disable-udp-tip", "Yalnız TCP işlədilib işlədilməyəcəyini idarə edir.\nBu seçim aktiv olduqda RustDesk artıq UDP 21116 işlətməyəcək, əvəzində TCP 21116 işlədiləcək."),
|
||||
("server-oss-not-support-tip", "QEYD: RustDesk server OSS bu funksiyanı əhatə etmir."),
|
||||
("input note here", "qeydi buraya yazın"),
|
||||
("note-at-conn-end-tip", "Əlaqə bitəndə qeyd soruş"),
|
||||
("Show terminal extra keys", "Terminalın əlavə düymələrini göstər"),
|
||||
("Relative mouse mode", "Nisbi siçan rejimi"),
|
||||
("rel-mouse-not-supported-peer-tip", "Qoşulan qarşı tərəf nisbi siçan rejimini dəstəkləmir."),
|
||||
("rel-mouse-not-ready-tip", "Nisbi siçan rejimi hələ hazır deyil. Yenidən cəhd edin."),
|
||||
("rel-mouse-lock-failed-tip", "Kursoru kilidləmək alınmadı. Nisbi siçan rejimi söndürüldü."),
|
||||
("rel-mouse-exit-{}-tip", "Çıxmaq üçün {} basın."),
|
||||
("rel-mouse-permission-lost-tip", "Klaviatura icazəsi geri alındı. Nisbi siçan rejimi söndürüldü."),
|
||||
("Changelog", "Dəyişikliklər siyahısı"),
|
||||
("keep-awake-during-outgoing-sessions-label", "Gedən sessiyalar zamanı ekranı oyaq saxla"),
|
||||
("keep-awake-during-incoming-sessions-label", "Gələn sessiyalar zamanı ekranı oyaq saxla"),
|
||||
("Continue with {}", "{} ilə davam et"),
|
||||
("Display Name", "Görünən ad"),
|
||||
("password-hidden-tip", "Daimi parol təyin edilib (gizlədilib)."),
|
||||
("preset-password-in-use-tip", "Hazırda öncədən təyin edilmiş parol işlədilir."),
|
||||
("Enable privacy mode", "Məxfilik rejimini aktivləşdir"),
|
||||
("allow-remote-toolbar-docking-any-edge", "Uzaq alətlər panelinin pəncərənin istənilən kənarına birləşməsinə icazə ver"),
|
||||
("API Token", "API tokeni"),
|
||||
("Deploy", "Yerləşdir"),
|
||||
("Custom ID (optional)", "Fərdi ID (istəyə bağlı)"),
|
||||
("server_requires_deployment_tip", "Server bu cihazın açıq şəkildə yerləşdirilməsini tələb edir. İndi yerləşdirilsin?"),
|
||||
("The server does not require explicit deployment.", "Server açıq yerləşdirmə tələb etmir."),
|
||||
("Unknown response.", "Naməlum cavab."),
|
||||
("wayland-keyboard-input-disabled-tip", "Klaviatura girişinə icazə verilsin?"),
|
||||
("wayland-keyboard-input-consent-tip", "Bu uzaq kompüterdə yazdıqlarınızı (parollar da daxil olmaqla) oradakı digər tətbiqlər oxuya bilər."),
|
||||
("wayland-keyboard-input-applies-to-tip", "Bu seçim buna aiddir:"),
|
||||
("wayland-soft-keyboard-input-label", "Ekran klaviaturası girişi"),
|
||||
("wayland-keyboard-input-reset-choice-tip", "Klaviatura girişi seçimini sıfırla"),
|
||||
("remember-wayland-keyboard-choice-tip", "Bu uzaq kompüter üçün bir daha soruşma"),
|
||||
("Why this happens", "Bu niyə baş verir"),
|
||||
("Switch display", "Ekranı dəyiş"),
|
||||
("Show monitor switch button on the main toolbar", "Monitor dəyişdirmə düyməsini əsas alətlər panelində göstər"),
|
||||
("Show on the minimized toolbar", "Kiçildilmiş alətlər panelində göstər"),
|
||||
("All monitors", "Bütün monitorlar"),
|
||||
("#{} monitor", "#{} monitor"),
|
||||
("conn-e2ee-unavailable-tip", "Uçdan-uca şifrələmə doğrulana bilmədi.\nUzaq cihaz hələ qurulma mərhələsində ola bilər. Sonra yenidən cəhd edin.\nBu təkrarlanırsa, server etibarsız ola bilər.\nYenə də davam edilsin?"),
|
||||
("ID whitelisting", "ID ağ siyahısı"),
|
||||
("Use ID whitelisting", "ID ağ siyahısından istifadə et"),
|
||||
("id_whitelist_tip", "Yalnız ağ siyahıdakı ID-lər mənə giriş edə bilər"),
|
||||
("id_whitelist_wildcard_tip", "Joker simvollar dəstəklənir: '*' istənilən sayda simvola, '?' isə tam bir simvola uyğun gəlir"),
|
||||
("Invalid ID", "Yanlış ID"),
|
||||
("Your ID is blocked by the peer", "ID-niz qarşı tərəf tərəfindən bloklanıb"),
|
||||
("Your ip is blocked by the peer", "IP-niz qarşı tərəf tərəfindən bloklanıb"),
|
||||
("id_whitelist_caveat_tip", "ID qoşulan klient tərəfindən bildirilir. Bu ağ siyahı riski azaldır, lakin parolu və ya 2FA-nı əvəz etmir."),
|
||||
("whitelist_cidr_tip", "CIDR yazılışı dəstəklənir, məsələn 192.168.1.0/24"),
|
||||
("Continue", "Davam et"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Brauzer açılmadı? Daxil olmaq üçün aşağıdakı URL-dən istifadə edin."),
|
||||
("Lock canvas", "Kətanı kilidlə"),
|
||||
("Sync clipboard between sessions", "Mübadilə buferini sessiyalar arasında sinxronlaşdır"),
|
||||
("sync-clipboard-between-sessions-tip", "Bir uzaq sessiyada kopyalanan mətn və ya şəkillər qoşulu olduğunuz digər sessiyaların mübadilə buferinə də göndərilir."),
|
||||
("terminal-clipboard-write-tip", "Terminaldakı tətbiq bu cihazın mübadilə buferinə mətn kopyalamaq istəyir. İcazə versəniz, bu icazə siz onu Parametrlərdə söndürənə qədər bütün əlaqələrdəki terminal tətbiqlərinə şamil olunur. Əl ilə kopyalama və yapışdırma buna daxil deyil."),
|
||||
("Allow terminal apps to copy to clipboard", "Terminal tətbiqlərinə mübadilə buferinə kopyalamağa icazə ver"),
|
||||
("Enable", "Aktivləşdir"),
|
||||
("Reuse one connection for port forwarding", "Port yönləndirmə üçün bir əlaqəni təkrar işlət"),
|
||||
("port-forward-mux-tip", "Port yönləndirmə xəritələnməsinin hər əlaqəsini qarşı tərəfə açılan tək əlaqə üzərindən daşıyır, hər biri üçün yenidən qoşulub giriş etmək əvəzinə."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P əlaqəsini aktivləşdir"),
|
||||
("Enable TCP hole punching", "TCP deşik açmanı aktivləşdir"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Выкарыстоўваць падключэнне WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Выкарыстоўваць TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Запыт на абагульванне экрана быў адхілены на аддаленай прыладзе"),
|
||||
("No one responded to the screen sharing request on the remote device", "Ніхто не адказаў на запыт абагульвання экрана на аддаленай прыладзе"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal завяршыў запыт на абагульванне экрана ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal не вярнуў экран для захопу, магчыма бібліятэка PipeWire занадта старая"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Адсутнічае плагін GStreamer, патрэбны для захопу экрана ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Час чакання запыту на абагульванне экрана на аддаленай прыладзе выйшаў"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk не можа атрымаць доступ да сеанса працоўнага стала на аддаленай прыладзе, праверце, ці запушчаны сеанс і ці даступны ён для RustDesk"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Партал працоўнага стала на аддаленай прыладзе не мае магчымасці, патрэбнай для абагульвання экрана або аддаленага кіравання, магчыма не ўсталяваны яго бэкенд"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Абагульванне экрана было дазволена на аддаленай прыладзе, але не ўдалося адкрыць злучэнне PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "Запыт на абагульванне экрана на аддаленай прыладзе завяршыўся, не будучы выкананым"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk не змог атрымаць прыдатны экран ад XDG Desktop Portal, магчыма бібліятэка PipeWire занадта старая"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk не змог загрузіць кампанент GStreamer, патрэбны для захопу экрана ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Позволяване на WebRTC P2P връзка"),
|
||||
("Enable TCP hole punching", "Позволяване на TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Заявката за споделяне на екрана беше отхвърлена на отдалеченото устройство"),
|
||||
("No one responded to the screen sharing request on the remote device", "Никой не отговори на заявката за споделяне на екрана на отдалеченото устройство"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal прекрати заявката за споделяне на екрана ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal не върна екран за заснемане, библиотеката PipeWire може да е твърде стара"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Липсва приставка на GStreamer, необходима за заснемане на екрана ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Заявката за споделяне на екрана на отдалеченото устройство изтече"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk не може да достигне сесията на работния плот на отдалеченото устройство, проверете дали сесията работи и дали RustDesk може да я използва"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Порталът на работния плот на отдалеченото устройство няма възможност, необходима за споделяне на екрана или отдалечено управление, може да липсва неговата реализация"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Споделянето на екрана беше одобрено на отдалеченото устройство, но връзката с PipeWire не можа да бъде отворена"),
|
||||
("The screen sharing request ended without completing on the remote device", "Заявката за споделяне на екрана на отдалеченото устройство приключи, без да бъде изпълнена"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk не можа да получи използваем екран от XDG Desktop Portal, библиотеката PipeWire може да е твърде стара"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk не можа да зареди компонент на GStreamer, необходим за заснемане на екрана ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Habilita la connexió WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Activa la perforació TCP"),
|
||||
("The screen sharing request was declined on the remote device", "La sol·licitud de compartició de pantalla s'ha rebutjat al dispositiu remot"),
|
||||
("No one responded to the screen sharing request on the remote device", "Ningú no ha respost a la sol·licitud de compartició de pantalla al dispositiu remot"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "L'XDG Desktop Portal ha finalitzat la sol·licitud de compartició de pantalla ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "L'XDG Desktop Portal no ha retornat cap pantalla per capturar; la biblioteca PipeWire pot ser massa antiga"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Falta un connector del GStreamer necessari per capturar la pantalla ({})"),
|
||||
("The screen sharing request timed out on the remote device", "La sol·licitud de compartició de pantalla ha esgotat el temps al dispositiu remot"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "El RustDesk no pot accedir a la sessió d'escriptori del dispositiu remot; comproveu que hi ha una sessió en marxa i que el RustDesk hi pot accedir"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Al portal d'escriptori del dispositiu remot li falta una funcionalitat necessària per compartir la pantalla o per al control remot; potser no té cap implementació instal·lada"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "S'ha aprovat la compartició de pantalla al dispositiu remot, però no s'ha pogut obrir la connexió PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "La sol·licitud de compartició de pantalla al dispositiu remot ha acabat sense completar-se"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "El RustDesk no ha pogut obtenir cap pantalla utilitzable de l'XDG Desktop Portal; la biblioteca PipeWire pot ser massa antiga"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "El RustDesk no ha pogut carregar un component del GStreamer necessari per capturar la pantalla ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "启用 WebRTC P2P 连接"),
|
||||
("Enable TCP hole punching", "启用 TCP 打洞"),
|
||||
("The screen sharing request was declined on the remote device", "远程设备上的用户拒绝了屏幕共享请求"),
|
||||
("No one responded to the screen sharing request on the remote device", "远程设备上无人响应屏幕共享请求"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal 结束了屏幕共享请求 ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal 未返回可捕获的屏幕,PipeWire 库可能过旧"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "缺少屏幕捕获所需的 GStreamer 插件 ({})"),
|
||||
("The screen sharing request timed out on the remote device", "远程设备上的屏幕共享请求超时了"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk 无法访问远程设备的桌面会话,请确认桌面会话已启动并且 RustDesk 可以使用它"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "远程设备上的桌面门户缺少屏幕共享或远程控制所需的功能,可能没有安装它的后端"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "远程设备上已批准屏幕共享,但无法打开 PipeWire 连接"),
|
||||
("The screen sharing request ended without completing on the remote device", "远程设备上的屏幕共享请求已结束,但未完成"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk 无法从 XDG Desktop Portal 获取可用的屏幕,PipeWire 库可能过旧"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk 无法加载屏幕捕获所需的 GStreamer 组件 ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Povolit připojení WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Povolit TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Žádost o sdílení obrazovky byla na vzdáleném zařízení odmítnuta"),
|
||||
("No one responded to the screen sharing request on the remote device", "Na žádost o sdílení obrazovky na vzdáleném zařízení nikdo neodpověděl"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal ukončil žádost o sdílení obrazovky ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal nevrátil žádnou obrazovku k zachycení, knihovna PipeWire může být příliš stará"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Chybí zásuvný modul GStreamer potřebný k zachycení obrazovky ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Vypršel časový limit žádosti o sdílení obrazovky na vzdáleném zařízení"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk nemůže získat přístup k relaci plochy na vzdáleném zařízení, ověřte, že relace běží a že ji RustDesk může použít"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Portálu plochy na vzdáleném zařízení chybí funkce potřebná pro sdílení obrazovky nebo vzdálené ovládání, jeho implementace možná není nainstalována"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Sdílení obrazovky bylo na vzdáleném zařízení schváleno, ale připojení PipeWire se nepodařilo otevřít"),
|
||||
("The screen sharing request ended without completing on the remote device", "Žádost o sdílení obrazovky na vzdáleném zařízení skončila, aniž by byla dokončena"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nezískal z XDG Desktop Portal použitelnou obrazovku, knihovna PipeWire může být příliš stará"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nemohl načíst komponentu GStreameru potřebnou k zachycení obrazovky ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Aktivér WebRTC P2P-forbindelse"),
|
||||
("Enable TCP hole punching", "Aktivér TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Anmodningen om skærmdeling blev afvist på fjernenheden"),
|
||||
("No one responded to the screen sharing request on the remote device", "Ingen svarede på anmodningen om skærmdeling på fjernenheden"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal afsluttede anmodningen om skærmdeling ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal returnerede ingen skærm at optage, PipeWire-biblioteket er måske for gammelt"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Et GStreamer-plugin, der kræves til skærmoptagelse, mangler ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Anmodningen om skærmdeling fik timeout på fjernenheden"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk kan ikke nå skrivebordssessionen på fjernenheden, kontrollér at en session kører, og at RustDesk kan bruge den"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Skrivebordsportalen på fjernenheden mangler en funktion, der kræves til skærmdeling eller fjernstyring, dens backend er måske ikke installeret"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Skærmdeling blev godkendt på fjernenheden, men PipeWire-forbindelsen kunne ikke åbnes"),
|
||||
("The screen sharing request ended without completing on the remote device", "Anmodningen om skærmdeling på fjernenheden sluttede uden at blive gennemført"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk kunne ikke få en brugbar skærm fra XDG Desktop Portal, PipeWire-biblioteket er måske for gammelt"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk kunne ikke indlæse en GStreamer-komponent, der kræves til skærmoptagelse ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC-P2P-Verbindung aktivieren"),
|
||||
("Enable TCP hole punching", "TCP-Hole-Punching aktivieren"),
|
||||
("The screen sharing request was declined on the remote device", "Die Anfrage zur Bildschirmfreigabe wurde auf dem entfernten Gerät abgelehnt"),
|
||||
("No one responded to the screen sharing request on the remote device", "Niemand hat auf die Anfrage zur Bildschirmfreigabe auf dem entfernten Gerät geantwortet"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal hat die Anfrage zur Bildschirmfreigabe beendet ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal hat keinen Bildschirm zur Aufnahme zurückgegeben, die PipeWire-Bibliothek ist möglicherweise zu alt"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Ein für die Bildschirmaufnahme benötigtes GStreamer-Plugin fehlt ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Bei der Anfrage zur Bildschirmfreigabe auf dem entfernten Gerät ist eine Zeitüberschreitung aufgetreten"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk kann die Desktop-Sitzung auf dem entfernten Gerät nicht erreichen. Prüfen Sie, ob eine Sitzung läuft und ob RustDesk sie nutzen kann"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Dem Desktop-Portal auf dem entfernten Gerät fehlt eine für Bildschirmfreigabe oder Fernsteuerung benötigte Fähigkeit, sein Backend ist möglicherweise nicht installiert"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Die Bildschirmfreigabe wurde auf dem entfernten Gerät genehmigt, aber die PipeWire-Verbindung konnte nicht geöffnet werden"),
|
||||
("The screen sharing request ended without completing on the remote device", "Die Anfrage zur Bildschirmfreigabe auf dem entfernten Gerät endete, ohne abgeschlossen zu werden"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk konnte vom XDG Desktop Portal keinen nutzbaren Bildschirm erhalten, die PipeWire-Bibliothek ist möglicherweise zu alt"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk konnte eine für die Bildschirmaufnahme benötigte GStreamer-Komponente nicht laden ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Ενεργοποίηση σύνδεσης WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Ενεργοποίηση διάτρησης οπών TCP"),
|
||||
("The screen sharing request was declined on the remote device", "Το αίτημα κοινής χρήσης οθόνης απορρίφθηκε στην απομακρυσμένη συσκευή"),
|
||||
("No one responded to the screen sharing request on the remote device", "Κανείς δεν απάντησε στο αίτημα κοινής χρήσης οθόνης στην απομακρυσμένη συσκευή"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "Το XDG Desktop Portal τερμάτισε το αίτημα κοινής χρήσης οθόνης ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "Το XDG Desktop Portal δεν επέστρεψε οθόνη για καταγραφή, η βιβλιοθήκη PipeWire ίσως είναι πολύ παλιά"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Λείπει ένα πρόσθετο GStreamer που απαιτείται για την καταγραφή οθόνης ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Το αίτημα κοινής χρήσης οθόνης έληξε στην απομακρυσμένη συσκευή"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "Το RustDesk δεν μπορεί να προσεγγίσει τη συνεδρία επιφάνειας εργασίας στην απομακρυσμένη συσκευή, ελέγξτε ότι μια συνεδρία εκτελείται και ότι το RustDesk μπορεί να τη χρησιμοποιήσει"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Στην πύλη επιφάνειας εργασίας της απομακρυσμένης συσκευής λείπει μια δυνατότητα που απαιτείται για κοινή χρήση οθόνης ή απομακρυσμένο έλεγχο, ίσως δεν είναι εγκατεστημένο το υποσύστημά της"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Η κοινή χρήση οθόνης εγκρίθηκε στην απομακρυσμένη συσκευή, αλλά δεν ήταν δυνατό το άνοιγμα της σύνδεσης PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "Το αίτημα κοινής χρήσης οθόνης στην απομακρυσμένη συσκευή έληξε χωρίς να ολοκληρωθεί"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "Το RustDesk δεν μπόρεσε να λάβει αξιοποιήσιμη οθόνη από το XDG Desktop Portal, η βιβλιοθήκη PipeWire ίσως είναι πολύ παλιά"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "Το RustDesk δεν μπόρεσε να φορτώσει ένα στοιχείο του GStreamer που απαιτείται για την καταγραφή οθόνης ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Ebligi WebRTC P2P-konekton"),
|
||||
("Enable TCP hole punching", "Ebligi TCP-trapikadon"),
|
||||
("The screen sharing request was declined on the remote device", "La peto pri ekrandividado estis rifuzita sur la fora aparato"),
|
||||
("No one responded to the screen sharing request on the remote device", "Neniu respondis al la peto pri ekrandividado sur la fora aparato"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal finis la peton pri ekrandividado ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal redonis neniun ekranon por kapti, la biblioteko PipeWire eble estas tro malnova"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Mankas kromprogramo de GStreamer necesa por ekrankapto ({})"),
|
||||
("The screen sharing request timed out on the remote device", "La peto pri ekrandividado eltempiĝis sur la fora aparato"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk ne povas atingi la labortablan seancon sur la fora aparato, kontrolu ke seanco funkcias kaj ke RustDesk povas uzi ĝin"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Al la labortabla portalo sur la fora aparato mankas kapablo necesa por ekrandividado aŭ fora regado, ĝia realigo eble ne estas instalita"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Ekrandividado estis aprobita sur la fora aparato, sed la konekto PipeWire ne malfermiĝis"),
|
||||
("The screen sharing request ended without completing on the remote device", "La peto pri ekrandividado sur la fora aparato finiĝis sen kompletiĝi"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk ne povis akiri uzeblan ekranon de XDG Desktop Portal, la biblioteko PipeWire eble estas tro malnova"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ne povis ŝargi komponanton de GStreamer necesan por ekrankapto ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Habilitar conexión WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Habilitar perforación de agujero TCP"),
|
||||
("The screen sharing request was declined on the remote device", "La solicitud de compartir pantalla fue rechazada en el dispositivo remoto"),
|
||||
("No one responded to the screen sharing request on the remote device", "Nadie respondió a la solicitud de compartir pantalla en el dispositivo remoto"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal finalizó la solicitud de compartir pantalla ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal no devolvió ninguna pantalla para capturar; la biblioteca PipeWire puede ser demasiado antigua"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Falta un complemento de GStreamer necesario para capturar la pantalla ({})"),
|
||||
("The screen sharing request timed out on the remote device", "La solicitud de compartir pantalla ha agotado el tiempo de espera en el dispositivo remoto"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk no puede acceder a la sesión de escritorio del dispositivo remoto; compruebe que hay una sesión en marcha y que RustDesk puede usarla"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Al portal de escritorio del dispositivo remoto le falta una función necesaria para compartir la pantalla o para el control remoto; puede que no tenga instalada su implementación"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Se aprobó compartir la pantalla en el dispositivo remoto, pero no se pudo abrir la conexión PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "La solicitud de compartir pantalla en el dispositivo remoto terminó sin completarse"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk no ha podido obtener una pantalla utilizable del XDG Desktop Portal; la biblioteca PipeWire puede ser demasiado antigua"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk no ha podido cargar un componente de GStreamer necesario para capturar la pantalla ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Luba WebRTC P2P-ühendus"),
|
||||
("Enable TCP hole punching", "Luba TCP-augustamine"),
|
||||
("The screen sharing request was declined on the remote device", "Ekraani jagamise taotlus lükati kaugseadmes tagasi"),
|
||||
("No one responded to the screen sharing request on the remote device", "Keegi ei vastanud kaugseadmes ekraani jagamise taotlusele"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal lõpetas ekraani jagamise taotluse ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal ei tagastanud ühtegi jäädvustatavat ekraani, PipeWire'i teek võib olla liiga vana"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Ekraani jäädvustamiseks vajalik GStreameri plugin puudub ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Ekraani jagamise taotlus aegus kaugseadmes"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk ei pääse kaugseadmes töölauaseansini, kontrollige, kas seanss töötab ja kas RustDesk saab seda kasutada"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Kaugseadme töölauaportaalil puudub ekraani jagamiseks või kaugjuhtimiseks vajalik võimalus, selle taustarakendus ei pruugi olla paigaldatud"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Ekraani jagamine kiideti kaugseadmes heaks, kuid PipeWire'i ühendust ei õnnestunud avada"),
|
||||
("The screen sharing request ended without completing on the remote device", "Ekraani jagamise taotlus kaugseadmes lõppes ilma lõpule jõudmata"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk ei saanud XDG Desktop Portalilt kasutatavat ekraani, PipeWire'i teek võib olla liiga vana"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ei suutnud laadida ekraani jäädvustamiseks vajalikku GStreameri komponenti ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Gaitu WebRTC P2P konexioa"),
|
||||
("Enable TCP hole punching", "Gaitu TCP zulo-egitea"),
|
||||
("The screen sharing request was declined on the remote device", "Pantaila partekatzeko eskaera baztertu egin da urruneko gailuan"),
|
||||
("No one responded to the screen sharing request on the remote device", "Inork ez du erantzun urruneko gailuko pantaila partekatzeko eskaerari"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal-ek pantaila partekatzeko eskaera amaitu du ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal-ek ez du kapturatzeko pantailarik itzuli, PipeWire liburutegia zaharregia izan daiteke"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Pantaila kapturatzeko beharrezkoa den GStreamer plugina falta da ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Pantaila partekatzeko eskaerak denbora-muga gainditu du urruneko gailuan"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk-ek ezin du urruneko gailuko mahaigaineko saioa atzitu, egiaztatu saio bat martxan dagoela eta RustDesk-ek erabil dezakeela"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Urruneko gailuko mahaigaineko atariari pantaila partekatzeko edo urrunetik kontrolatzeko behar den gaitasun bat falta zaio, agian ez dago haren backend-a instalatuta"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Pantaila partekatzea onartu da urruneko gailuan, baina ezin izan da PipeWire konexioa ireki"),
|
||||
("The screen sharing request ended without completing on the remote device", "Urruneko gailuko pantaila partekatzeko eskaera osatu gabe amaitu da"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk-ek ezin izan du pantaila erabilgarririk lortu XDG Desktop Portal-etik, PipeWire liburutegia zaharregia izan daiteke"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk-ek ezin izan du pantaila kapturatzeko beharrezkoa den GStreamer osagai bat kargatu ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "فعالسازی اتصال همتابههمتای WebRTC"),
|
||||
("Enable TCP hole punching", "فعالسازی تکنیک TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "درخواست اشتراکگذاری صفحه در دستگاه راه دور رد شد"),
|
||||
("No one responded to the screen sharing request on the remote device", "هیچکس به درخواست اشتراکگذاری صفحه در دستگاه راه دور پاسخ نداد"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal درخواست اشتراکگذاری صفحه را پایان داد ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal هیچ صفحهای برای ضبط بازنگرداند، ممکن است کتابخانه PipeWire خیلی قدیمی باشد"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "افزونه GStreamer موردنیاز برای ضبط صفحه موجود نیست ({})"),
|
||||
("The screen sharing request timed out on the remote device", "مهلت درخواست اشتراکگذاری صفحه در دستگاه راه دور به پایان رسید"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk نمیتواند به نشست میزکار دستگاه راه دور دسترسی پیدا کند، بررسی کنید که نشست میزکار در حال اجرا باشد و RustDesk بتواند از آن استفاده کند"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "درگاه میزکار در دستگاه راه دور قابلیت لازم برای اشتراکگذاری صفحه یا کنترل از راه دور را ندارد، شاید پیادهسازی آن نصب نشده باشد"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "اشتراکگذاری صفحه در دستگاه راه دور تأیید شد، اما اتصال PipeWire باز نشد"),
|
||||
("The screen sharing request ended without completing on the remote device", "درخواست اشتراکگذاری صفحه در دستگاه راه دور بدون تکمیل شدن پایان یافت"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk نتوانست صفحهای قابل استفاده از XDG Desktop Portal دریافت کند، ممکن است کتابخانه PipeWire خیلی قدیمی باشد"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk نتوانست مؤلفه GStreamer موردنیاز برای ضبط صفحه را بارگذاری کند ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Ota WebRTC P2P yhteys käyttöön"),
|
||||
("Enable TCP hole punching", "Ota käyttöön TCP hole punching tekniikka"),
|
||||
("The screen sharing request was declined on the remote device", "Näytön jakamispyyntö hylättiin etälaitteessa"),
|
||||
("No one responded to the screen sharing request on the remote device", "Kukaan ei vastannut näytön jakamispyyntöön etälaitteessa"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal päätti näytön jakamispyynnön ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal ei palauttanut kaapattavaa näyttöä, PipeWire-kirjasto voi olla liian vanha"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Näytön kaappaukseen tarvittava GStreamer-liitännäinen puuttuu ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Näytön jakamispyyntö aikakatkaistiin etälaitteessa"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk ei tavoita etälaitteen työpöytäistuntoa, tarkista että istunto on käynnissä ja että RustDesk voi käyttää sitä"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Etälaitteen työpöytäportaalista puuttuu näytön jakamiseen tai etäohjaukseen tarvittava ominaisuus, sen taustaosaa ei ehkä ole asennettu"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Näytön jakaminen hyväksyttiin etälaitteessa, mutta PipeWire-yhteyttä ei voitu avata"),
|
||||
("The screen sharing request ended without completing on the remote device", "Näytön jakamispyyntö etälaitteessa päättyi ilman että se saatiin valmiiksi"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk ei saanut XDG Desktop Portalilta käyttökelpoista näyttöä, PipeWire-kirjasto voi olla liian vanha"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ei voinut ladata näytön kaappaukseen tarvittavaa GStreamer-osaa ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Activer la connexion P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Activer le « hole punching » TCP"),
|
||||
("The screen sharing request was declined on the remote device", "La demande de partage d'écran a été refusée sur l'appareil distant"),
|
||||
("No one responded to the screen sharing request on the remote device", "Personne n'a répondu à la demande de partage d'écran sur l'appareil distant"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal a mis fin à la demande de partage d'écran ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal n'a renvoyé aucun écran à capturer, la bibliothèque PipeWire est peut-être trop ancienne"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Un greffon GStreamer nécessaire à la capture d'écran est manquant ({})"),
|
||||
("The screen sharing request timed out on the remote device", "La demande de partage d'écran a expiré sur l'appareil distant"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk ne peut pas accéder à la session de bureau de l'appareil distant, vérifiez qu'une session est ouverte et que RustDesk peut l'utiliser"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Il manque au portail de bureau de l'appareil distant une fonctionnalité nécessaire au partage d'écran ou au contrôle à distance, son backend n'est peut-être pas installé"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Le partage d'écran a été approuvé sur l'appareil distant, mais la connexion PipeWire n'a pas pu être ouverte"),
|
||||
("The screen sharing request ended without completing on the remote device", "La demande de partage d'écran sur l'appareil distant s'est terminée sans aboutir"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk n'a pas pu obtenir d'écran exploitable auprès du XDG Desktop Portal, la bibliothèque PipeWire est peut-être trop ancienne"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk n'a pas pu charger un composant GStreamer nécessaire à la capture d'écran ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P კავშირის ჩართვა"),
|
||||
("Enable TCP hole punching", "TCP hole punching-ის ჩართვა"),
|
||||
("The screen sharing request was declined on the remote device", "ეკრანის გაზიარების მოთხოვნა უარყოფილია დისტანციურ მოწყობილობაზე"),
|
||||
("No one responded to the screen sharing request on the remote device", "დისტანციურ მოწყობილობაზე ეკრანის გაზიარების მოთხოვნას არავინ უპასუხა"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal-მა დაასრულა ეკრანის გაზიარების მოთხოვნა ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal-მა არ დააბრუნა ჩასაწერი ეკრანი, PipeWire-ის ბიბლიოთეკა შესაძლოა ძალიან ძველია"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "ეკრანის ჩაწერისთვის საჭირო GStreamer-ის მოდული აკლია ({})"),
|
||||
("The screen sharing request timed out on the remote device", "ეკრანის გაზიარების მოთხოვნას ვადა გაუვიდა დისტანციურ მოწყობილობაზე"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk-ს არ შეუძლია დისტანციური მოწყობილობის სამუშაო მაგიდის სესიასთან წვდომა, შეამოწმეთ, რომ სესია გაშვებულია და RustDesk-ს შეუძლია მისი გამოყენება"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "დისტანციური მოწყობილობის სამუშაო მაგიდის პორტალს აკლია ეკრანის გაზიარებისთვის ან დისტანციური მართვისთვის საჭირო შესაძლებლობა, შესაძლოა მისი ბექენდი დაინსტალირებული არ არის"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "ეკრანის გაზიარება დამტკიცდა დისტანციურ მოწყობილობაზე, მაგრამ PipeWire-ის კავშირის გახსნა ვერ მოხერხდა"),
|
||||
("The screen sharing request ended without completing on the remote device", "ეკრანის გაზიარების მოთხოვნა დისტანციურ მოწყობილობაზე დასრულდა შეუსრულებლად"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk-მა ვერ მიიღო გამოსადეგი ეკრანი XDG Desktop Portal-იდან, PipeWire-ის ბიბლიოთეკა შესაძლოა ძალიან ძველია"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk-მა ვერ ჩატვირთა ეკრანის ჩაწერისთვის საჭირო GStreamer-ის კომპონენტი ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Activar conexión P2P por WebRTC"),
|
||||
("Enable TCP hole punching", "Activar perforación de portos TCP"),
|
||||
("The screen sharing request was declined on the remote device", "A solicitude de compartir pantalla foi rexeitada no dispositivo remoto"),
|
||||
("No one responded to the screen sharing request on the remote device", "Ninguén respondeu á solicitude de compartir pantalla no dispositivo remoto"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal rematou a solicitude de compartir pantalla ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal non devolveu ningunha pantalla para capturar, a biblioteca PipeWire pode ser demasiado antiga"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Falta un complemento de GStreamer necesario para capturar a pantalla ({})"),
|
||||
("The screen sharing request timed out on the remote device", "A solicitude de compartir pantalla esgotou o tempo no dispositivo remoto"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk non pode acceder á sesión de escritorio do dispositivo remoto, comprobe que hai unha sesión en marcha e que RustDesk pode usala"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Ao portal de escritorio do dispositivo remoto fáltalle unha funcionalidade necesaria para compartir a pantalla ou para o control remoto, pode que non teña instalada a súa implementación"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Aprobouse compartir a pantalla no dispositivo remoto, pero non se puido abrir a conexión PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "A solicitude de compartir pantalla no dispositivo remoto rematou sen completarse"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk non puido obter unha pantalla utilizable do XDG Desktop Portal, a biblioteca PipeWire pode ser demasiado antiga"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk non puido cargar un compoñente de GStreamer necesario para capturar a pantalla ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P કનેક્શન સક્ષમ કરો"),
|
||||
("Enable TCP hole punching", "TCP હોલ પંચિંગ સક્ષમ કરો"),
|
||||
("The screen sharing request was declined on the remote device", "રિમોટ ઉપકરણ પર સ્ક્રીન શેરિંગ વિનંતી નકારવામાં આવી"),
|
||||
("No one responded to the screen sharing request on the remote device", "રિમોટ ઉપકરણ પર સ્ક્રીન શેરિંગ વિનંતીનો કોઈએ જવાબ આપ્યો નથી"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal એ સ્ક્રીન શેરિંગ વિનંતી સમાપ્ત કરી ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal એ કૅપ્ચર કરવા માટે કોઈ સ્ક્રીન પરત કરી નથી, PipeWire લાઇબ્રેરી કદાચ ઘણી જૂની છે"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "સ્ક્રીન કૅપ્ચર માટે જરૂરી GStreamer પ્લગઇન ખૂટે છે ({})"),
|
||||
("The screen sharing request timed out on the remote device", "રિમોટ ઉપકરણ પર સ્ક્રીન શેરિંગ વિનંતીનો સમય સમાપ્ત થયો"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk રિમોટ ઉપકરણના ડેસ્કટોપ સત્ર સુધી પહોંચી શકતું નથી, ખાતરી કરો કે ડેસ્કટોપ સત્ર ચાલુ છે અને RustDesk તેનો ઉપયોગ કરી શકે છે"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "રિમોટ ઉપકરણ પરના ડેસ્કટોપ પોર્ટલમાં સ્ક્રીન શેરિંગ અથવા રિમોટ કંટ્રોલ માટે જરૂરી ક્ષમતા નથી, તેનું બેકએન્ડ કદાચ ઇન્સ્ટોલ કરેલું નથી"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "રિમોટ ઉપકરણ પર સ્ક્રીન શેરિંગ મંજૂર થયું, પરંતુ PipeWire કનેક્શન ખોલી શકાયું નહીં"),
|
||||
("The screen sharing request ended without completing on the remote device", "રિમોટ ઉપકરણ પર સ્ક્રીન શેરિંગ વિનંતી પૂર્ણ થયા વિના સમાપ્ત થઈ"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk XDG Desktop Portal પાસેથી ઉપયોગી સ્ક્રીન મેળવી શક્યું નથી, PipeWire લાઇબ્રેરી કદાચ ઘણી જૂની છે"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk સ્ક્રીન કૅપ્ચર માટે જરૂરી GStreamer ઘટક લોડ કરી શક્યું નથી ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "אפשר חיבור WebRTC P2P"),
|
||||
("Enable TCP hole punching", "אפשר TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "בקשת שיתוף המסך נדחתה במכשיר המרוחק"),
|
||||
("No one responded to the screen sharing request on the remote device", "איש לא הגיב לבקשת שיתוף המסך במכשיר המרוחק"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal סיים את בקשת שיתוף המסך ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal לא החזיר מסך ללכידה, ייתכן שספריית PipeWire ישנה מדי"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "חסר תוסף GStreamer הדרוש ללכידת מסך ({})"),
|
||||
("The screen sharing request timed out on the remote device", "תם הזמן המוקצב לבקשת שיתוף המסך במכשיר המרוחק"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk אינו יכול לגשת להפעלת שולחן העבודה במכשיר המרוחק, ודאו שההפעלה פועלת ושRustDesk יכול להשתמש בה"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "לפורטל שולחן העבודה במכשיר המרוחק חסרה יכולת הדרושה לשיתוף מסך או לשליטה מרחוק, ייתכן שהמימוש שלו אינו מותקן"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "שיתוף המסך אושר במכשיר המרוחק, אך לא ניתן היה לפתוח את חיבור PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "בקשת שיתוף המסך במכשיר המרוחק הסתיימה מבלי להתבצע"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk לא הצליח לקבל מסך שמיש מ-XDG Desktop Portal, ייתכן שספריית PipeWire ישנה מדי"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk לא הצליח לטעון רכיב GStreamer הדרוש ללכידת מסך ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P कनेक्शन सक्षम करें"),
|
||||
("Enable TCP hole punching", "TCP होल पंचिंग सक्षम करें"),
|
||||
("The screen sharing request was declined on the remote device", "रिमोट डिवाइस पर स्क्रीन शेयरिंग अनुरोध अस्वीकार कर दिया गया"),
|
||||
("No one responded to the screen sharing request on the remote device", "रिमोट डिवाइस पर स्क्रीन शेयरिंग अनुरोध का किसी ने उत्तर नहीं दिया"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal ने स्क्रीन शेयरिंग अनुरोध समाप्त कर दिया ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal ने कैप्चर करने के लिए कोई स्क्रीन नहीं लौटाई, PipeWire लाइब्रेरी बहुत पुरानी हो सकती है"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "स्क्रीन कैप्चर के लिए आवश्यक GStreamer प्लगइन अनुपस्थित है ({})"),
|
||||
("The screen sharing request timed out on the remote device", "रिमोट डिवाइस पर स्क्रीन शेयरिंग अनुरोध का समय समाप्त हो गया"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk रिमोट डिवाइस के डेस्कटॉप सत्र तक नहीं पहुँच सकता, जाँचें कि डेस्कटॉप सत्र चल रहा है और RustDesk उसका उपयोग कर सकता है"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "रिमोट डिवाइस के डेस्कटॉप पोर्टल में स्क्रीन शेयरिंग या रिमोट कंट्रोल के लिए आवश्यक क्षमता नहीं है, शायद उसका बैकएंड इंस्टॉल नहीं है"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "रिमोट डिवाइस पर स्क्रीन शेयरिंग स्वीकृत हुई, लेकिन PipeWire कनेक्शन नहीं खोला जा सका"),
|
||||
("The screen sharing request ended without completing on the remote device", "रिमोट डिवाइस पर स्क्रीन शेयरिंग अनुरोध पूरा हुए बिना समाप्त हो गया"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk XDG Desktop Portal से उपयोग योग्य स्क्रीन प्राप्त नहीं कर सका, PipeWire लाइब्रेरी बहुत पुरानी हो सकती है"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk स्क्रीन कैप्चर के लिए आवश्यक GStreamer घटक लोड नहीं कर सका ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Omogući WebRTC P2P vezu"),
|
||||
("Enable TCP hole punching", "Omogući TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Zahtjev za dijeljenje zaslona odbijen je na udaljenom uređaju"),
|
||||
("No one responded to the screen sharing request on the remote device", "Nitko nije odgovorio na zahtjev za dijeljenje zaslona na udaljenom uređaju"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal prekinuo je zahtjev za dijeljenje zaslona ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal nije vratio zaslon za snimanje, PipeWire biblioteka je možda prestara"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Nedostaje GStreamer dodatak potreban za snimanje zaslona ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Zahtjev za dijeljenje zaslona istekao je na udaljenom uređaju"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk ne može pristupiti sesiji radne površine na udaljenom uređaju, provjerite radi li sesija i može li je RustDesk koristiti"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Portalu radne površine na udaljenom uređaju nedostaje mogućnost potrebna za dijeljenje zaslona ili daljinsko upravljanje, njegov pozadinski dio možda nije instaliran"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Dijeljenje zaslona odobreno je na udaljenom uređaju, ali PipeWire vezu nije bilo moguće otvoriti"),
|
||||
("The screen sharing request ended without completing on the remote device", "Zahtjev za dijeljenje zaslona na udaljenom uređaju završio je bez dovršetka"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nije mogao dobiti upotrebljiv zaslon od XDG Desktop Portala, PipeWire biblioteka je možda prestara"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nije mogao učitati GStreamer komponentu potrebnu za snimanje zaslona ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P kapcsolat engedélyezése"),
|
||||
("Enable TCP hole punching", "TCP résszűrés engedélyezése"),
|
||||
("The screen sharing request was declined on the remote device", "A képernyőmegosztási kérést elutasították a távoli eszközön"),
|
||||
("No one responded to the screen sharing request on the remote device", "Senki sem válaszolt a képernyőmegosztási kérésre a távoli eszközön"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "Az XDG Desktop Portal befejezte a képernyőmegosztási kérést ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "Az XDG Desktop Portal nem adott vissza rögzíthető képernyőt, a PipeWire programkönyvtár túl régi lehet"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Hiányzik a képernyőrögzítéshez szükséges GStreamer bővítmény ({})"),
|
||||
("The screen sharing request timed out on the remote device", "A képernyőmegosztási kérés időtúllépést okozott a távoli eszközön"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "A RustDesk nem éri el az asztali munkamenetet a távoli eszközön, ellenőrizze, hogy fut-e munkamenet és hogy a RustDesk használhatja-e"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "A távoli eszköz asztali portáljából hiányzik a képernyőmegosztáshoz vagy távvezérléshez szükséges képesség, a háttérrendszere talán nincs telepítve"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "A képernyőmegosztást jóváhagyták a távoli eszközön, de a PipeWire-kapcsolatot nem sikerült megnyitni"),
|
||||
("The screen sharing request ended without completing on the remote device", "A képernyőmegosztási kérés a távoli eszközön befejeződött anélkül, hogy teljesült volna"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "A RustDesk nem kapott használható képernyőt az XDG Desktop Portaltól, a PipeWire programkönyvtár túl régi lehet"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "A RustDesk nem tudta betölteni a képernyőrögzítéshez szükséges GStreamer összetevőt ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Aktifkan koneksi P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Aktifkan TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Permintaan berbagi layar ditolak di perangkat jarak jauh"),
|
||||
("No one responded to the screen sharing request on the remote device", "Tidak ada yang menanggapi permintaan berbagi layar di perangkat jarak jauh"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal mengakhiri permintaan berbagi layar ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal tidak mengembalikan layar untuk direkam, pustaka PipeWire mungkin terlalu lama"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Plugin GStreamer yang diperlukan untuk merekam layar tidak ditemukan ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Permintaan berbagi layar kehabisan waktu di perangkat jarak jauh"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk tidak dapat mengakses sesi desktop di perangkat jarak jauh, pastikan sesi desktop berjalan dan dapat digunakan oleh RustDesk"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Portal desktop di perangkat jarak jauh tidak memiliki kemampuan yang diperlukan untuk berbagi layar atau kendali jarak jauh, backend-nya mungkin belum terpasang"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Berbagi layar disetujui di perangkat jarak jauh, tetapi koneksi PipeWire tidak dapat dibuka"),
|
||||
("The screen sharing request ended without completing on the remote device", "Permintaan berbagi layar di perangkat jarak jauh berakhir tanpa diselesaikan"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk tidak mendapatkan layar yang dapat digunakan dari XDG Desktop Portal, pustaka PipeWire mungkin terlalu lama"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk tidak dapat memuat komponen GStreamer yang diperlukan untuk merekam layar ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Abilita connessione P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Abilita hole punching TCP"),
|
||||
("The screen sharing request was declined on the remote device", ""),
|
||||
("No one responded to the screen sharing request on the remote device", ""),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", ""),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", ""),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", ""),
|
||||
("The screen sharing request timed out on the remote device", ""),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", ""),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", ""),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", ""),
|
||||
("The screen sharing request ended without completing on the remote device", ""),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", ""),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P 接続を有効化する"),
|
||||
("Enable TCP hole punching", "TCP ホールパンチを有効化する"),
|
||||
("The screen sharing request was declined on the remote device", "リモート端末で画面共有の要求が拒否されました"),
|
||||
("No one responded to the screen sharing request on the remote device", "リモート端末で画面共有の要求に誰も応答しませんでした"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal が画面共有の要求を終了しました ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal がキャプチャ対象の画面を返しませんでした。PipeWire ライブラリが古すぎる可能性があります"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "画面キャプチャに必要な GStreamer プラグインがありません ({})"),
|
||||
("The screen sharing request timed out on the remote device", "リモート端末で画面共有の要求がタイムアウトしました"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk はリモート端末のデスクトップセッションにアクセスできません。セッションが動作していて RustDesk から利用できることを確認してください"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "リモート端末のデスクトップポータルに画面共有または遠隔操作に必要な機能がありません。バックエンドが未インストールの可能性があります"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "リモート端末で画面共有は許可されましたが、PipeWire 接続を開けませんでした"),
|
||||
("The screen sharing request ended without completing on the remote device", "リモート端末での画面共有の要求は完了しないまま終了しました"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk は XDG Desktop Portal から使用可能な画面を取得できませんでした。PipeWire ライブラリが古すぎる可能性があります"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk は画面キャプチャに必要な GStreamer コンポーネントを読み込めませんでした ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P 연결 사용"),
|
||||
("Enable TCP hole punching", "TCP 홀 펀칭 사용"),
|
||||
("The screen sharing request was declined on the remote device", "원격 장치에서 화면 공유 요청이 거부되었습니다"),
|
||||
("No one responded to the screen sharing request on the remote device", "원격 장치에서 아무도 화면 공유 요청에 응답하지 않았습니다"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal이 화면 공유 요청을 종료했습니다 ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal이 캡처할 화면을 반환하지 않았습니다. PipeWire 라이브러리가 너무 오래되었을 수 있습니다"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "화면 캡처에 필요한 GStreamer 플러그인이 없습니다 ({})"),
|
||||
("The screen sharing request timed out on the remote device", "원격 장치에서 화면 공유 요청이 시간 초과되었습니다"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk가 원격 장치의 데스크톱 세션에 접근할 수 없습니다. 세션이 실행 중이고 RustDesk가 사용할 수 있는지 확인하세요"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "원격 장치의 데스크톱 포털에 화면 공유 또는 원격 제어에 필요한 기능이 없습니다. 백엔드가 설치되지 않았을 수 있습니다"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "원격 장치에서 화면 공유가 승인되었지만 PipeWire 연결을 열 수 없습니다"),
|
||||
("The screen sharing request ended without completing on the remote device", "원격 장치의 화면 공유 요청이 완료되지 않은 채 종료되었습니다"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk가 XDG Desktop Portal에서 사용 가능한 화면을 가져오지 못했습니다. PipeWire 라이브러리가 너무 오래되었을 수 있습니다"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk가 화면 캡처에 필요한 GStreamer 구성 요소를 불러오지 못했습니다 ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P қосылымын іске қосу"),
|
||||
("Enable TCP hole punching", "TCP hole punching'ті іске қосу"),
|
||||
("The screen sharing request was declined on the remote device", "Қашықтағы құрылғыда экранды бөлісу сұрауы қабылданбады"),
|
||||
("No one responded to the screen sharing request on the remote device", "Қашықтағы құрылғыда экранды бөлісу сұрауына ешкім жауап бермеді"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal экранды бөлісу сұрауын аяқтады ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal түсіруге арналған экран қайтармады, PipeWire кітапханасы тым ескі болуы мүмкін"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Экранды түсіру үшін қажет GStreamer плагині жоқ ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Қашықтағы құрылғыда экранды бөлісу сұрауының уақыты бітті"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk қашықтағы құрылғының жұмыс үстелі сеансына қол жеткізе алмайды, сеанстың іске қосылғанын және RustDesk оны пайдалана алатынын тексеріңіз"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Қашықтағы құрылғының жұмыс үстелі порталында экранды бөлісуге немесе қашықтан басқаруға қажет мүмкіндік жоқ, оның бэкенді орнатылмаған болуы мүмкін"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Қашықтағы құрылғыда экранды бөлісуге рұқсат берілді, бірақ PipeWire байланысын ашу мүмкін болмады"),
|
||||
("The screen sharing request ended without completing on the remote device", "Қашықтағы құрылғыдағы экранды бөлісу сұрауы аяқталмай тоқтады"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk XDG Desktop Portal-дан жарамды экран ала алмады, PipeWire кітапханасы тым ескі болуы мүмкін"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk экранды түсіру үшін қажет GStreamer компонентін жүктей алмады ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Įgalinti WebRTC P2P ryšį"),
|
||||
("Enable TCP hole punching", "Įgalinti TCP gręžimą (hole punching)"),
|
||||
("The screen sharing request was declined on the remote device", "Ekrano bendrinimo užklausa buvo atmesta nuotoliniame įrenginyje"),
|
||||
("No one responded to the screen sharing request on the remote device", "Niekas neatsakė į ekrano bendrinimo užklausą nuotoliniame įrenginyje"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal užbaigė ekrano bendrinimo užklausą ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal negrąžino jokio ekrano įrašymui, PipeWire biblioteka gali būti per sena"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Trūksta ekrano įrašymui reikalingo GStreamer papildinio ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Baigėsi ekrano bendrinimo užklausos laikas nuotoliniame įrenginyje"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk negali pasiekti nuotolinio įrenginio darbalaukio seanso, patikrinkite, ar seansas veikia ir ar RustDesk gali jį naudoti"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Nuotolinio įrenginio darbalaukio portalui trūksta ekrano bendrinimui ar nuotoliniam valdymui reikalingos galimybės, gali būti neįdiegta jo posistemė"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Ekrano bendrinimas nuotoliniame įrenginyje buvo patvirtintas, bet nepavyko atverti PipeWire ryšio"),
|
||||
("The screen sharing request ended without completing on the remote device", "Ekrano bendrinimo užklausa nuotoliniame įrenginyje baigėsi jos neužbaigus"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk negavo tinkamo ekrano iš XDG Desktop Portal, PipeWire biblioteka gali būti per sena"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nepavyko įkelti ekrano įrašymui reikalingo GStreamer komponento ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Iespējot WebRTC P2P savienojumu"),
|
||||
("Enable TCP hole punching", "Iespējot TCP caurumu veidošanu"),
|
||||
("The screen sharing request was declined on the remote device", "Ekrāna koplietošanas pieprasījums attālinātajā ierīcē tika noraidīts"),
|
||||
("No one responded to the screen sharing request on the remote device", "Neviens neatbildēja uz ekrāna koplietošanas pieprasījumu attālinātajā ierīcē"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal pārtrauca ekrāna koplietošanas pieprasījumu ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal neatgrieza nevienu tveramo ekrānu, PipeWire bibliotēka var būt pārāk veca"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Trūkst ekrāna tveršanai nepieciešamā GStreamer spraudņa ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Ekrāna koplietošanas pieprasījumam attālinātajā ierīcē iestājās noildze"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk nevar piekļūt attālinātās ierīces darbvirsmas sesijai, pārbaudiet, vai sesija darbojas un vai RustDesk to var izmantot"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Attālinātās ierīces darbvirsmas portālam trūkst ekrāna koplietošanai vai attālinātai vadībai nepieciešamās iespējas, tā aizmugursistēma varētu nebūt instalēta"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Ekrāna koplietošana attālinātajā ierīcē tika apstiprināta, bet PipeWire savienojumu neizdevās atvērt"),
|
||||
("The screen sharing request ended without completing on the remote device", "Ekrāna koplietošanas pieprasījums attālinātajā ierīcē beidzās, netiekot pabeigts"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk neieguva izmantojamu ekrānu no XDG Desktop Portal, PipeWire bibliotēka var būt pārāk veca"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nevarēja ielādēt ekrāna tveršanai nepieciešamo GStreamer komponentu ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P കണക്ഷൻ അനുവദിക്കുക"),
|
||||
("Enable TCP hole punching", "TCP ഹോൾ പഞ്ചിംഗ് അനുവദിക്കുക"),
|
||||
("The screen sharing request was declined on the remote device", "വിദൂര ഉപകരണത്തിൽ സ്ക്രീൻ പങ്കിടൽ അഭ്യർത്ഥന നിരസിച്ചു"),
|
||||
("No one responded to the screen sharing request on the remote device", "വിദൂര ഉപകരണത്തിലെ സ്ക്രീൻ പങ്കിടൽ അഭ്യർത്ഥനയോട് ആരും പ്രതികരിച്ചില്ല"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal സ്ക്രീൻ പങ്കിടൽ അഭ്യർത്ഥന അവസാനിപ്പിച്ചു ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal പകർത്താൻ ഒരു സ്ക്രീനും നൽകിയില്ല, PipeWire ലൈബ്രറി വളരെ പഴയതാകാം"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "സ്ക്രീൻ പകർത്താൻ ആവശ്യമായ GStreamer പ്ലഗിൻ ലഭ്യമല്ല ({})"),
|
||||
("The screen sharing request timed out on the remote device", "വിദൂര ഉപകരണത്തിൽ സ്ക്രീൻ പങ്കിടൽ അഭ്യർത്ഥനയുടെ സമയം കഴിഞ്ഞു"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk-ന് വിദൂര ഉപകരണത്തിലെ ഡെസ്ക്ടോപ്പ് സെഷനിലേക്ക് എത്താൻ കഴിയുന്നില്ല, സെഷൻ പ്രവർത്തിക്കുന്നുണ്ടെന്നും RustDesk-ന് അത് ഉപയോഗിക്കാമെന്നും ഉറപ്പാക്കുക"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "വിദൂര ഉപകരണത്തിലെ ഡെസ്ക്ടോപ്പ് പോർട്ടലിന് സ്ക്രീൻ പങ്കിടലിനോ വിദൂര നിയന്ത്രണത്തിനോ ആവശ്യമായ ശേഷിയില്ല, അതിന്റെ ബാക്കെൻഡ് ഇൻസ്റ്റാൾ ചെയ്തിട്ടില്ലായിരിക്കാം"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "വിദൂര ഉപകരണത്തിൽ സ്ക്രീൻ പങ്കിടൽ അനുവദിച്ചു, പക്ഷേ PipeWire കണക്ഷൻ തുറക്കാനായില്ല"),
|
||||
("The screen sharing request ended without completing on the remote device", "വിദൂര ഉപകരണത്തിലെ സ്ക്രീൻ പങ്കിടൽ അഭ്യർത്ഥന പൂർത്തിയാകാതെ അവസാനിച്ചു"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "XDG Desktop Portal-ൽ നിന്ന് ഉപയോഗയോഗ്യമായ സ്ക്രീൻ RustDesk-ന് ലഭിച്ചില്ല, PipeWire ലൈബ്രറി വളരെ പഴയതാകാം"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "സ്ക്രീൻ പകർത്താൻ ആവശ്യമായ GStreamer ഘടകം RustDesk-ന് ലോഡ് ചെയ്യാനായില്ല ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Aktiver WebRTC P2P-tilkobling"),
|
||||
("Enable TCP hole punching", "Aktiver TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Forespørselen om skjermdeling ble avvist på den eksterne enheten"),
|
||||
("No one responded to the screen sharing request on the remote device", "Ingen svarte på forespørselen om skjermdeling på den eksterne enheten"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal avsluttet forespørselen om skjermdeling ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal returnerte ingen skjerm å ta opp, PipeWire-biblioteket kan være for gammelt"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Et GStreamer-tillegg som kreves for skjermopptak mangler ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Forespørselen om skjermdeling fikk tidsavbrudd på den eksterne enheten"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk får ikke tilgang til skrivebordsøkten på den eksterne enheten, kontroller at en økt kjører og at RustDesk kan bruke den"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Skrivebordsportalen på den eksterne enheten mangler en funksjon som kreves for skjermdeling eller fjernstyring, bakstykket er kanskje ikke installert"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Skjermdeling ble godkjent på den eksterne enheten, men PipeWire-tilkoblingen kunne ikke åpnes"),
|
||||
("The screen sharing request ended without completing on the remote device", "Forespørselen om skjermdeling på den eksterne enheten ble avsluttet uten å bli fullført"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk fikk ingen brukbar skjerm fra XDG Desktop Portal, PipeWire-biblioteket kan være for gammelt"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk kunne ikke laste en GStreamer-komponent som kreves for skjermopptak ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P-verbinding inschakelen"),
|
||||
("Enable TCP hole punching", "TCP-hole punching inschakelen"),
|
||||
("The screen sharing request was declined on the remote device", "Het verzoek om schermdeling is geweigerd op het externe apparaat"),
|
||||
("No one responded to the screen sharing request on the remote device", "Niemand heeft gereageerd op het verzoek om schermdeling op het externe apparaat"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal heeft het verzoek om schermdeling beëindigd ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal heeft geen scherm teruggegeven om op te nemen, de PipeWire-bibliotheek is mogelijk te oud"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Een GStreamer-plug-in die nodig is voor schermopname ontbreekt ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Het verzoek om schermdeling is verlopen op het externe apparaat"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk heeft geen toegang tot de bureaubladsessie op het externe apparaat, controleer of er een sessie actief is en of RustDesk die kan gebruiken"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "De bureaubladportal op het externe apparaat mist een functie die nodig is voor schermdeling of besturing op afstand, de backend is mogelijk niet geïnstalleerd"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Schermdeling is goedgekeurd op het externe apparaat, maar de PipeWire-verbinding kon niet worden geopend"),
|
||||
("The screen sharing request ended without completing on the remote device", "Het verzoek om schermdeling op het externe apparaat is geëindigd zonder te zijn voltooid"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk kon geen bruikbaar scherm verkrijgen van de XDG Desktop Portal, de PipeWire-bibliotheek is mogelijk te oud"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk kon een GStreamer-component die nodig is voor schermopname niet laden ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Włącz połączenie P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Włącz tworzenie tunelu TCP"),
|
||||
("The screen sharing request was declined on the remote device", "Żądanie udostępnienia ekranu zostało odrzucone na urządzeniu zdalnym"),
|
||||
("No one responded to the screen sharing request on the remote device", "Nikt nie odpowiedział na żądanie udostępnienia ekranu na urządzeniu zdalnym"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal zakończył żądanie udostępnienia ekranu ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal nie zwrócił żadnego ekranu do przechwycenia, biblioteka PipeWire może być zbyt stara"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Brak wtyczki GStreamer wymaganej do przechwytywania ekranu ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Upłynął limit czasu żądania udostępnienia ekranu na urządzeniu zdalnym"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk nie może uzyskać dostępu do sesji pulpitu na urządzeniu zdalnym, sprawdź, czy sesja działa i czy RustDesk może z niej korzystać"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Portalowi pulpitu na urządzeniu zdalnym brakuje funkcji wymaganej do udostępniania ekranu lub zdalnego sterowania, jego zaplecze może nie być zainstalowane"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Udostępnianie ekranu zostało zatwierdzone na urządzeniu zdalnym, ale nie udało się otworzyć połączenia PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "Żądanie udostępnienia ekranu na urządzeniu zdalnym zakończyło się bez ukończenia"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nie uzyskał użytecznego ekranu z XDG Desktop Portal, biblioteka PipeWire może być zbyt stara"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nie mógł załadować składnika GStreamer wymaganego do przechwytywania ekranu ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Ativar ligação P2P por WebRTC"),
|
||||
("Enable TCP hole punching", "Ativar TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "O pedido de partilha de ecrã foi recusado no dispositivo remoto"),
|
||||
("No one responded to the screen sharing request on the remote device", "Ninguém respondeu ao pedido de partilha de ecrã no dispositivo remoto"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "O XDG Desktop Portal terminou o pedido de partilha de ecrã ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "O XDG Desktop Portal não devolveu qualquer ecrã para capturar, a biblioteca PipeWire pode ser demasiado antiga"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Falta um plugin do GStreamer necessário para capturar o ecrã ({})"),
|
||||
("The screen sharing request timed out on the remote device", "O pedido de partilha de ecrã expirou no dispositivo remoto"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "O RustDesk não consegue aceder à sessão de ambiente de trabalho no dispositivo remoto, verifique se existe uma sessão ativa e se o RustDesk a pode usar"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Falta ao portal de ambiente de trabalho do dispositivo remoto uma capacidade necessária para partilha de ecrã ou controlo remoto, o seu backend pode não estar instalado"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "A partilha de ecrã foi aprovada no dispositivo remoto, mas não foi possível abrir a ligação PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "O pedido de partilha de ecrã no dispositivo remoto terminou sem ser concluído"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "O RustDesk não conseguiu obter um ecrã utilizável do XDG Desktop Portal, a biblioteca PipeWire pode ser demasiado antiga"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "O RustDesk não conseguiu carregar um componente do GStreamer necessário para capturar o ecrã ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Habilitar conexão WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Habilitar TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "A solicitação de compartilhamento de tela foi recusada no dispositivo remoto"),
|
||||
("No one responded to the screen sharing request on the remote device", "Ninguém respondeu à solicitação de compartilhamento de tela no dispositivo remoto"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "O XDG Desktop Portal encerrou a solicitação de compartilhamento de tela ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "O XDG Desktop Portal não retornou nenhuma tela para capturar, a biblioteca PipeWire pode ser muito antiga"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Falta um plugin do GStreamer necessário para capturar a tela ({})"),
|
||||
("The screen sharing request timed out on the remote device", "A solicitação de compartilhamento de tela expirou no dispositivo remoto"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "O RustDesk não consegue acessar a sessão de área de trabalho no dispositivo remoto, verifique se há uma sessão em execução e se o RustDesk pode usá-la"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Falta ao portal de área de trabalho do dispositivo remoto um recurso necessário para compartilhamento de tela ou controle remoto, seu backend pode não estar instalado"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "O compartilhamento de tela foi aprovado no dispositivo remoto, mas não foi possível abrir a conexão PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "A solicitação de compartilhamento de tela no dispositivo remoto terminou sem ser concluída"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "O RustDesk não conseguiu obter uma tela utilizável do XDG Desktop Portal, a biblioteca PipeWire pode ser muito antiga"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "O RustDesk não conseguiu carregar um componente do GStreamer necessário para capturar a tela ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Activează conexiunea P2P prin WebRTC"),
|
||||
("Enable TCP hole punching", "Activează traversarea TCP (hole punching)"),
|
||||
("The screen sharing request was declined on the remote device", "Cererea de partajare a ecranului a fost refuzată pe dispozitivul de la distanță"),
|
||||
("No one responded to the screen sharing request on the remote device", "Nimeni nu a răspuns la cererea de partajare a ecranului pe dispozitivul de la distanță"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal a încheiat cererea de partajare a ecranului ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal nu a returnat niciun ecran de capturat, biblioteca PipeWire poate fi prea veche"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Lipsește un plugin GStreamer necesar pentru capturarea ecranului ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Cererea de partajare a ecranului a expirat pe dispozitivul de la distanță"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk nu poate accesa sesiunea de desktop de pe dispozitivul de la distanță, verificați dacă o sesiune rulează și dacă RustDesk o poate folosi"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Portalului de desktop de pe dispozitivul de la distanță îi lipsește o funcționalitate necesară pentru partajarea ecranului sau controlul de la distanță, componenta sa de bază poate lipsi"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Partajarea ecranului a fost aprobată pe dispozitivul de la distanță, dar conexiunea PipeWire nu a putut fi deschisă"),
|
||||
("The screen sharing request ended without completing on the remote device", "Cererea de partajare a ecranului pe dispozitivul de la distanță s-a încheiat fără a fi finalizată"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nu a putut obține un ecran utilizabil de la XDG Desktop Portal, biblioteca PipeWire poate fi prea veche"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nu a putut încărca o componentă GStreamer necesară pentru capturarea ecranului ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Использовать подключение WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Использовать TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Запрос на демонстрацию экрана отклонён на удалённом устройстве"),
|
||||
("No one responded to the screen sharing request on the remote device", "Никто не ответил на запрос демонстрации экрана на удалённом устройстве"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal завершил запрос на демонстрацию экрана ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal не вернул экран для захвата, библиотека PipeWire может быть слишком старой"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Отсутствует плагин GStreamer, необходимый для захвата экрана ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Истекло время ожидания запроса на демонстрацию экрана на удалённом устройстве"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk не может получить доступ к сеансу рабочего стола на удалённом устройстве, проверьте, что сеанс запущен и доступен RustDesk"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Порталу рабочего стола на удалённом устройстве не хватает возможности, необходимой для демонстрации экрана или удалённого управления, его реализация может быть не установлена"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Демонстрация экрана была разрешена на удалённом устройстве, но не удалось открыть соединение PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "Запрос на демонстрацию экрана на удалённом устройстве завершился, не будучи выполненным"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk не смог получить пригодный экран от XDG Desktop Portal, библиотека PipeWire может быть слишком старой"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk не удалось загрузить компонент GStreamer, необходимый для захвата экрана ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Abìlita connessione P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Abìlita s'istampadura TCP"),
|
||||
("The screen sharing request was declined on the remote device", "Sa rechesta de cumpartzidura de sa schermada est istada refudada in su dispositivu remotu"),
|
||||
("No one responded to the screen sharing request on the remote device", "Nemos at rispostu a sa rechesta de cumpartzidura de sa schermada in su dispositivu remotu"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal at acabadu sa rechesta de cumpartzidura de sa schermada ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal no at torradu peruna schermada de registrare, sa libreria PipeWire podet èssere tropu betza"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Mancat unu plugin de GStreamer netzessàriu pro registrare sa schermada ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Sa rechesta de cumpartzidura de sa schermada at superadu su tempus in su dispositivu remotu"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk no podet acèdere a sa sessione de iscrivania in su dispositivu remotu, controlla chi una sessione siat ativa e chi RustDesk la potzat impreare"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "A su portale de iscrivania in su dispositivu remotu li mancat una funtzionalidade netzessària pro sa cumpartzidura de sa schermada o pro su controllu remotu, su backend suo podet non èssere installadu"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Sa cumpartzidura de sa schermada est istada aprovada in su dispositivu remotu, ma no si est pòdidu abèrrere sa connessione PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "Sa rechesta de cumpartzidura de sa schermada in su dispositivu remotu est acabada chene si cumpletare"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk no at pòdidu otènnere una schermada impreabile dae XDG Desktop Portal, sa libreria PipeWire podet èssere tropu betza"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk no at pòdidu carrigare unu cumponente de GStreamer netzessàriu pro registrare sa schermada ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Povoliť pripojenie WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Povoliť TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Žiadosť o zdieľanie obrazovky bola na vzdialenom zariadení odmietnutá"),
|
||||
("No one responded to the screen sharing request on the remote device", "Na žiadosť o zdieľanie obrazovky na vzdialenom zariadení nikto neodpovedal"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal ukončil žiadosť o zdieľanie obrazovky ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal nevrátil žiadnu obrazovku na zachytenie, knižnica PipeWire môže byť príliš stará"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Chýba zásuvný modul GStreamer potrebný na zachytenie obrazovky ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Vypršal časový limit žiadosti o zdieľanie obrazovky na vzdialenom zariadení"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk nemá prístup k relácii plochy na vzdialenom zariadení, overte, či relácia beží a či ju RustDesk môže použiť"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Portálu plochy na vzdialenom zariadení chýba funkcia potrebná na zdieľanie obrazovky alebo vzdialené ovládanie, jeho implementácia možno nie je nainštalovaná"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Zdieľanie obrazovky bolo na vzdialenom zariadení schválené, ale pripojenie PipeWire sa nepodarilo otvoriť"),
|
||||
("The screen sharing request ended without completing on the remote device", "Žiadosť o zdieľanie obrazovky na vzdialenom zariadení sa skončila bez dokončenia"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nezískal z XDG Desktop Portal použiteľnú obrazovku, knižnica PipeWire môže byť príliš stará"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nedokázal načítať komponent GStreamera potrebný na zachytenie obrazovky ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Omogoči povezavo WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Omogoči preboj lukenj TCP"),
|
||||
("The screen sharing request was declined on the remote device", "Zahteva za skupno rabo zaslona je bila na oddaljeni napravi zavrnjena"),
|
||||
("No one responded to the screen sharing request on the remote device", "Nihče ni odgovoril na zahtevo za skupno rabo zaslona na oddaljeni napravi"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal je končal zahtevo za skupno rabo zaslona ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal ni vrnil nobenega zaslona za zajem, knjižnica PipeWire je morda prestara"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Manjka vtičnik GStreamer, potreben za zajem zaslona ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Zahteva za skupno rabo zaslona je na oddaljeni napravi potekla"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk ne more dostopati do namizne seje na oddaljeni napravi, preverite, ali seja teče in ali jo RustDesk lahko uporablja"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Namiznemu portalu na oddaljeni napravi manjka zmožnost, potrebna za skupno rabo zaslona ali oddaljeno upravljanje, njegovo zaledje morda ni nameščeno"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Skupna raba zaslona je bila na oddaljeni napravi odobrena, vendar povezave PipeWire ni bilo mogoče odpreti"),
|
||||
("The screen sharing request ended without completing on the remote device", "Zahteva za skupno rabo zaslona na oddaljeni napravi se je končala, ne da bi bila dokončana"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk od XDG Desktop Portala ni dobil uporabnega zaslona, knjižnica PipeWire je morda prestara"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ni mogel naložiti komponente GStreamer, potrebne za zajem zaslona ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Aktivizo lidhjen WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Aktivizo TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Kërkesa për ndarjen e ekranit u refuzua në pajisjen e largët"),
|
||||
("No one responded to the screen sharing request on the remote device", "Askush nuk iu përgjigj kërkesës për ndarjen e ekranit në pajisjen e largët"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal e përfundoi kërkesën për ndarjen e ekranit ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal nuk ktheu asnjë ekran për regjistrim, biblioteka PipeWire mund të jetë shumë e vjetër"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Mungon një shtojcë e GStreamer e nevojshme për regjistrimin e ekranit ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Kërkesa për ndarjen e ekranit skadoi në pajisjen e largët"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk nuk mund të arrijë sesionin e desktopit në pajisjen e largët, kontrolloni që një sesion desktopi po funksionon dhe që RustDesk mund ta përdorë"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Portalit të desktopit në pajisjen e largët i mungon një aftësi e nevojshme për ndarjen e ekranit ose kontrollin në distancë, backend-i i tij mund të mos jetë i instaluar"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Ndarja e ekranit u miratua në pajisjen e largët, por lidhja PipeWire nuk mund të hapej"),
|
||||
("The screen sharing request ended without completing on the remote device", "Kërkesa për ndarjen e ekranit në pajisjen e largët përfundoi pa u kryer"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nuk mori një ekran të përdorshëm nga XDG Desktop Portal, biblioteka PipeWire mund të jetë shumë e vjetër"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nuk mundi të ngarkojë një komponent të GStreamer të nevojshëm për regjistrimin e ekranit ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Omogući WebRTC P2P konekciju"),
|
||||
("Enable TCP hole punching", "Omogući TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Zahtev za deljenje ekrana je odbijen na udaljenom uređaju"),
|
||||
("No one responded to the screen sharing request on the remote device", "Niko nije odgovorio na zahtev za deljenje ekrana na udaljenom uređaju"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal je završio zahtev za deljenje ekrana ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal nije vratio nijedan ekran za snimanje, PipeWire biblioteka je možda prestara"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Nedostaje GStreamer dodatak potreban za snimanje ekrana ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Zahtev za deljenje ekrana je istekao na udaljenom uređaju"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk ne može da pristupi sesiji radne površine na udaljenom uređaju, proverite da li sesija radi i da li RustDesk može da je koristi"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Portalu radne površine na udaljenom uređaju nedostaje mogućnost potrebna za deljenje ekrana ili daljinsko upravljanje, njegov pozadinski deo možda nije instaliran"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Deljenje ekrana je odobreno na udaljenom uređaju, ali PipeWire vezu nije bilo moguće otvoriti"),
|
||||
("The screen sharing request ended without completing on the remote device", "Zahtev za deljenje ekrana na udaljenom uređaju završio se bez dovršetka"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk nije mogao da dobije upotrebljiv ekran od XDG Desktop Portala, PipeWire biblioteka je možda prestara"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nije mogao da učita GStreamer komponentu potrebnu za snimanje ekrana ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Aktivera WebRTC P2P anslutning"),
|
||||
("Enable TCP hole punching", "Aktivera TCP hålslagning"),
|
||||
("The screen sharing request was declined on the remote device", "Begäran om skärmdelning avvisades på fjärrenheten"),
|
||||
("No one responded to the screen sharing request on the remote device", "Ingen svarade på begäran om skärmdelning på fjärrenheten"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal avslutade begäran om skärmdelning ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal returnerade ingen skärm att spela in, PipeWire-biblioteket kan vara för gammalt"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "En GStreamer-insticksmodul som krävs för skärminspelning saknas ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Begäran om skärmdelning nådde tidsgränsen på fjärrenheten"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk kan inte nå skrivbordssessionen på fjärrenheten, kontrollera att en session körs och att RustDesk kan använda den"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Skrivbordsportalen på fjärrenheten saknar en funktion som krävs för skärmdelning eller fjärrstyrning, dess bakände är kanske inte installerad"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Skärmdelning godkändes på fjärrenheten, men PipeWire-anslutningen kunde inte öppnas"),
|
||||
("The screen sharing request ended without completing on the remote device", "Begäran om skärmdelning på fjärrenheten avslutades utan att slutföras"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk fick ingen användbar skärm från XDG Desktop Portal, PipeWire-biblioteket kan vara för gammalt"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk kunde inte läsa in en GStreamer-komponent som krävs för skärminspelning ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P இணைப்பு இயக்கு"),
|
||||
("Enable TCP hole punching", "TCP hole punching இயக்கு"),
|
||||
("The screen sharing request was declined on the remote device", "தொலைநிலை சாதனத்தில் திரை பகிர்வு கோரிக்கை நிராகரிக்கப்பட்டது"),
|
||||
("No one responded to the screen sharing request on the remote device", "தொலைநிலை சாதனத்தில் திரை பகிர்வு கோரிக்கைக்கு யாரும் பதிலளிக்கவில்லை"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal திரை பகிர்வு கோரிக்கையை முடித்தது ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal பதிவு செய்ய எந்தத் திரையையும் வழங்கவில்லை, PipeWire நூலகம் மிகவும் பழையதாக இருக்கலாம்"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "திரைப் பதிவுக்குத் தேவையான GStreamer செருகுநிரல் இல்லை ({})"),
|
||||
("The screen sharing request timed out on the remote device", "தொலைநிலை சாதனத்தில் திரை பகிர்வு கோரிக்கையின் நேரம் முடிந்தது"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk தொலைநிலை சாதனத்தின் டெஸ்க்டாப் அமர்வை அணுக முடியவில்லை, ஒரு அமர்வு இயங்குகிறதா என்பதையும் RustDesk அதைப் பயன்படுத்த முடியுமா என்பதையும் சரிபார்க்கவும்"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "தொலைநிலை சாதனத்தின் டெஸ்க்டாப் போர்ட்டலில் திரை பகிர்வுக்கோ தொலை கட்டுப்பாட்டுக்கோ தேவையான திறன் இல்லை, அதன் பின்தளம் நிறுவப்படாமல் இருக்கலாம்"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "தொலைநிலை சாதனத்தில் திரை பகிர்வு அனுமதிக்கப்பட்டது, ஆனால் PipeWire இணைப்பைத் திறக்க முடியவில்லை"),
|
||||
("The screen sharing request ended without completing on the remote device", "தொலைநிலை சாதனத்தில் திரை பகிர்வு கோரிக்கை நிறைவடையாமல் முடிந்தது"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "XDG Desktop Portal-லிருந்து பயன்படுத்தக்கூடிய திரையை RustDesk பெற முடியவில்லை, PipeWire நூலகம் மிகவும் பழையதாக இருக்கலாம்"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "திரைப் பதிவுக்குத் தேவையான GStreamer கூறை RustDesk ஏற்ற முடியவில்லை ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", ""),
|
||||
("Enable TCP hole punching", ""),
|
||||
("The screen sharing request was declined on the remote device", ""),
|
||||
("No one responded to the screen sharing request on the remote device", ""),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", ""),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", ""),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", ""),
|
||||
("The screen sharing request timed out on the remote device", ""),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", ""),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", ""),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", ""),
|
||||
("The screen sharing request ended without completing on the remote device", ""),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", ""),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "เปิดใช้งานการเชื่อมต่อ P2P แบบ WebRTC"),
|
||||
("Enable TCP hole punching", "เปิดใช้งาน TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "คำขอแชร์หน้าจอถูกปฏิเสธบนอุปกรณ์ระยะไกล"),
|
||||
("No one responded to the screen sharing request on the remote device", "ไม่มีผู้ใดตอบรับคำขอแชร์หน้าจอบนอุปกรณ์ระยะไกล"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal ได้ยุติคำขอแชร์หน้าจอ ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal ไม่ได้ส่งคืนหน้าจอสำหรับการบันทึก ไลบรารี PipeWire อาจเก่าเกินไป"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "ไม่พบปลั๊กอิน GStreamer ที่จำเป็นสำหรับการบันทึกหน้าจอ ({})"),
|
||||
("The screen sharing request timed out on the remote device", "คำขอแชร์หน้าจอบนอุปกรณ์ระยะไกลหมดเวลา"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk ไม่สามารถเข้าถึงเซสชันเดสก์ท็อปบนอุปกรณ์ระยะไกล ตรวจสอบว่าเซสชันเดสก์ท็อปกำลังทำงานและ RustDesk ใช้งานได้"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "พอร์ทัลเดสก์ท็อปบนอุปกรณ์ระยะไกลขาดความสามารถที่จำเป็นสำหรับการแชร์หน้าจอหรือการควบคุมระยะไกล แบ็กเอนด์ของมันอาจยังไม่ได้ติดตั้ง"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "การแชร์หน้าจอได้รับอนุญาตบนอุปกรณ์ระยะไกลแล้ว แต่ไม่สามารถเปิดการเชื่อมต่อ PipeWire ได้"),
|
||||
("The screen sharing request ended without completing on the remote device", "คำขอแชร์หน้าจอบนอุปกรณ์ระยะไกลสิ้นสุดลงโดยไม่เสร็จสมบูรณ์"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk ไม่สามารถรับหน้าจอที่ใช้งานได้จาก XDG Desktop Portal ไลบรารี PipeWire อาจเก่าเกินไป"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ไม่สามารถโหลดส่วนประกอบ GStreamer ที่จำเป็นสำหรับการบันทึกหน้าจอได้ ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P bağlantısını etkinleştir"),
|
||||
("Enable TCP hole punching", "TCP delik açmayı etkinleştir"),
|
||||
("The screen sharing request was declined on the remote device", "Ekran paylaşımı isteği uzak cihazda reddedildi"),
|
||||
("No one responded to the screen sharing request on the remote device", "Uzak cihazdaki ekran paylaşımı isteğine kimse yanıt vermedi"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal ekran paylaşımı isteğini sonlandırdı ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal yakalanacak ekran döndürmedi, PipeWire kitaplığı çok eski olabilir"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Ekran yakalama için gereken GStreamer eklentisi eksik ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Uzak cihazdaki ekran paylaşımı isteği zaman aşımına uğradı"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk uzak cihazdaki masaüstü oturumuna erişemiyor, bir masaüstü oturumunun çalıştığını ve RustDesk tarafından kullanılabildiğini doğrulayın"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Uzak cihazdaki masaüstü portalında ekran paylaşımı veya uzaktan denetim için gereken bir yetenek yok, arka ucu kurulu olmayabilir"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Ekran paylaşımı uzak cihazda onaylandı, ancak PipeWire bağlantısı açılamadı"),
|
||||
("The screen sharing request ended without completing on the remote device", "Uzak cihazdaki ekran paylaşımı isteği tamamlanmadan sona erdi"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk, XDG Desktop Portal'dan kullanılabilir bir ekran alamadı, PipeWire kitaplığı çok eski olabilir"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ekran yakalama için gereken GStreamer bileşenini yükleyemedi ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "啟用 WebRTC P2P 連線"),
|
||||
("Enable TCP hole punching", "啟用 TCP 打洞"),
|
||||
("The screen sharing request was declined on the remote device", "遠端裝置上的使用者拒絕了螢幕分享要求"),
|
||||
("No one responded to the screen sharing request on the remote device", "遠端裝置上無人回應螢幕分享要求"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal 結束了螢幕分享要求 ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal 未傳回可擷取的螢幕,PipeWire 函式庫可能過舊"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "缺少螢幕擷取所需的 GStreamer 外掛程式 ({})"),
|
||||
("The screen sharing request timed out on the remote device", "遠端裝置上的螢幕分享要求逾時了"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk 無法存取遠端裝置的桌面工作階段,請確認工作階段已啟動且 RustDesk 可以使用它"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "遠端裝置上的桌面入口缺少螢幕分享或遠端控制所需的功能,可能沒有安裝它的後端"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "遠端裝置上已核准螢幕分享,但無法開啟 PipeWire 連線"),
|
||||
("The screen sharing request ended without completing on the remote device", "遠端裝置上的螢幕分享要求已結束,但未完成"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk 無法從 XDG Desktop Portal 取得可用的螢幕,PipeWire 函式庫可能過舊"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk 無法載入螢幕擷取所需的 GStreamer 元件 ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Увімкнути P2P-підключення через WebRTC"),
|
||||
("Enable TCP hole punching", "Увімкнути TCP hole punching"),
|
||||
("The screen sharing request was declined on the remote device", "Запит на демонстрацію екрана відхилено на віддаленому пристрої"),
|
||||
("No one responded to the screen sharing request on the remote device", "Ніхто не відповів на запит демонстрації екрана на віддаленому пристрої"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal завершив запит на демонстрацію екрана ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal не повернув екран для захоплення, бібліотека PipeWire може бути застарою"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Відсутній плагін GStreamer, потрібний для захоплення екрана ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Час очікування запиту на демонстрацію екрана на віддаленому пристрої вичерпано"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk не може отримати доступ до сеансу стільниці на віддаленому пристрої, перевірте, чи запущено сеанс і чи може RustDesk його використовувати"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Порталу стільниці на віддаленому пристрої бракує можливості, потрібної для демонстрації екрана або віддаленого керування, його реалізацію може бути не встановлено"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Демонстрацію екрана було схвалено на віддаленому пристрої, але не вдалося відкрити з'єднання PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "Запит на демонстрацію екрана на віддаленому пристрої завершився, не будучи виконаним"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk не зміг отримати придатний екран від XDG Desktop Portal, бібліотека PipeWire може бути застарою"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk не вдалося завантажити компонент GStreamer, потрібний для захоплення екрана ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -771,10 +771,13 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P کنکشن کو فعال کریں"),
|
||||
("Enable TCP hole punching", "TCP ہول پنچنگ کو فعال کریں"),
|
||||
("The screen sharing request was declined on the remote device", "ریموٹ ڈیوائس پر اسکرین شیئرنگ کی درخواست مسترد کر دی گئی"),
|
||||
("No one responded to the screen sharing request on the remote device", "ریموٹ ڈیوائس پر اسکرین شیئرنگ کی درخواست کا کسی نے جواب نہیں دیا"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal نے اسکرین شیئرنگ کی درخواست ختم کر دی ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal نے ریکارڈنگ کے لیے کوئی اسکرین واپس نہیں کی، PipeWire لائبریری شاید بہت پرانی ہے"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "اسکرین ریکارڈنگ کے لیے درکار GStreamer پلگ ان موجود نہیں ہے ({})"),
|
||||
("The screen sharing request timed out on the remote device", "ریموٹ ڈیوائس پر اسکرین شیئرنگ کی درخواست کا وقت ختم ہو گیا"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk ریموٹ ڈیوائس کے ڈیسک ٹاپ سیشن تک رسائی حاصل نہیں کر سکتا، تصدیق کریں کہ ڈیسک ٹاپ سیشن چل رہا ہے اور RustDesk اسے استعمال کر سکتا ہے"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "ریموٹ ڈیوائس کے ڈیسک ٹاپ پورٹل میں اسکرین شیئرنگ یا ریموٹ کنٹرول کے لیے درکار صلاحیت موجود نہیں، شاید اس کا بیک اینڈ نصب نہیں ہے"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "ریموٹ ڈیوائس پر اسکرین شیئرنگ کی منظوری مل گئی، لیکن PipeWire کنکشن نہیں کھولا جا سکا"),
|
||||
("The screen sharing request ended without completing on the remote device", "ریموٹ ڈیوائس پر اسکرین شیئرنگ کی درخواست مکمل ہوئے بغیر ختم ہو گئی"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk کو XDG Desktop Portal سے قابلِ استعمال اسکرین نہیں مل سکی، PipeWire لائبریری شاید بہت پرانی ہے"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk اسکرین ریکارڈنگ کے لیے درکار GStreamer جزو لوڈ نہیں کر سکا ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
|
||||
@@ -771,9 +771,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Enable WebRTC P2P connection", "Cho phép kết nối WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Bật TCP Hole Punching"),
|
||||
("The screen sharing request was declined on the remote device", "Yêu cầu chia sẻ màn hình đã bị từ chối trên thiết bị từ xa"),
|
||||
("No one responded to the screen sharing request on the remote device", "Không có ai phản hồi yêu cầu chia sẻ màn hình trên thiết bị từ xa"),
|
||||
("The XDG Desktop Portal ended the screen sharing request ({})", "XDG Desktop Portal đã kết thúc yêu cầu chia sẻ màn hình ({})"),
|
||||
("The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old", "XDG Desktop Portal không trả về màn hình nào để ghi, thư viện PipeWire có thể quá cũ"),
|
||||
("A GStreamer plugin needed for screen capture is missing ({})", "Thiếu phần bổ trợ GStreamer cần cho việc ghi màn hình ({})"),
|
||||
("The screen sharing request timed out on the remote device", "Yêu cầu chia sẻ màn hình đã hết thời gian chờ trên thiết bị từ xa"),
|
||||
("RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it", "RustDesk không thể truy cập phiên màn hình nền trên thiết bị từ xa, hãy kiểm tra rằng một phiên đang chạy và RustDesk có thể dùng nó"),
|
||||
("The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed", "Cổng màn hình nền trên thiết bị từ xa thiếu một khả năng cần cho chia sẻ màn hình hoặc điều khiển từ xa, phần nền của nó có thể chưa được cài đặt"),
|
||||
("Screen sharing was approved on the remote device, but the PipeWire connection could not be opened", "Chia sẻ màn hình đã được chấp thuận trên thiết bị từ xa, nhưng không thể mở kết nối PipeWire"),
|
||||
("The screen sharing request ended without completing on the remote device", "Yêu cầu chia sẻ màn hình trên thiết bị từ xa đã kết thúc mà chưa hoàn tất"),
|
||||
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "RustDesk không lấy được màn hình dùng được từ XDG Desktop Portal, thư viện PipeWire có thể quá cũ"),
|
||||
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk không thể tải một thành phần GStreamer cần cho việc ghi màn hình ({})"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
72
src/server/audio_capture_error.rs
Normal file
72
src/server/audio_capture_error.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
// Each stream owns its flag so a late callback cannot restart a replacement.
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct CaptureErrorHandler {
|
||||
interrupted: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CaptureErrorHandler {
|
||||
pub(super) fn handle(&self, error: cpal::StreamError) {
|
||||
if matches!(error, cpal::StreamError::StreamInterrupted { .. }) {
|
||||
// ScreenCaptureKit can stop capture while the remote session stays open.
|
||||
// The observed -3821 error does not identify its underlying trigger.
|
||||
// https://developer.apple.com/documentation/screencapturekit/scstreamdelegate/stream(_:didstopwitherror:)
|
||||
hbb_common::log::error!("Audio capture stream interrupted: {error}");
|
||||
self.interrupted.store(true, Ordering::Relaxed);
|
||||
} else {
|
||||
// Keep frequent sample-buffer errors at the existing trace level.
|
||||
hbb_common::log::trace!("an error occurred on stream: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn needs_restart(&self) -> bool {
|
||||
self.interrupted.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cpal::{BackendSpecificError, StreamError};
|
||||
|
||||
fn system_interruption() -> StreamError {
|
||||
StreamError::StreamInterrupted {
|
||||
err: BackendSpecificError {
|
||||
description: "ScreenCaptureKit system-stopped capture (-3821)".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_interruption_requests_recreation_of_the_active_stream() {
|
||||
let errors = CaptureErrorHandler::default();
|
||||
let callback = errors.clone();
|
||||
assert!(!errors.needs_restart());
|
||||
callback.handle(system_interruption());
|
||||
assert!(errors.needs_restart());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_error_from_an_old_stream_does_not_restart_its_replacement() {
|
||||
let old_callback = CaptureErrorHandler::default();
|
||||
let replacement = CaptureErrorHandler::default();
|
||||
old_callback.handle(system_interruption());
|
||||
assert!(old_callback.needs_restart());
|
||||
assert!(!replacement.needs_restart());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_backend_errors_do_not_request_recreation() {
|
||||
let errors = CaptureErrorHandler::default();
|
||||
errors.handle(StreamError::BackendSpecific {
|
||||
err: BackendSpecificError {
|
||||
description: "A sample buffer could not be read".to_owned(),
|
||||
},
|
||||
});
|
||||
assert!(!errors.needs_restart());
|
||||
}
|
||||
}
|
||||
@@ -171,9 +171,14 @@ pub fn is_screen_capture_kit_available() -> bool {
|
||||
.any(|host| *host == cpal::HostId::ScreenCaptureKit)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
#[path = "audio_capture_error.rs"]
|
||||
mod audio_capture_error;
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
mod cpal_impl {
|
||||
use self::service::{Reset, ServiceSwap};
|
||||
use super::audio_capture_error::CaptureErrorHandler;
|
||||
use super::*;
|
||||
use cpal::{
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
@@ -192,7 +197,7 @@ mod cpal_impl {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
stream: Option<(Box<dyn StreamTrait>, Arc<Message>)>,
|
||||
stream: Option<(Box<dyn StreamTrait>, Arc<Message>, CaptureErrorHandler)>,
|
||||
}
|
||||
|
||||
impl super::service::Reset for State {
|
||||
@@ -210,8 +215,10 @@ mod cpal_impl {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some((_, format)) = &state.stream {
|
||||
if let Some((_, format, _)) = &state.stream {
|
||||
sp.send_shared(format.clone());
|
||||
#[cfg(target_os = "macos")]
|
||||
log::info!("Audio capture stream recreated; replacement format sent");
|
||||
}
|
||||
RESTARTING.store(false, Ordering::SeqCst);
|
||||
Ok(())
|
||||
@@ -225,7 +232,7 @@ mod cpal_impl {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some((_, format)) = &state.stream {
|
||||
if let Some((_, format, _)) = &state.stream {
|
||||
sps.send_shared(format.clone());
|
||||
}
|
||||
Ok(())
|
||||
@@ -234,6 +241,13 @@ mod cpal_impl {
|
||||
}
|
||||
|
||||
pub fn run(sp: EmptyExtraFieldService, state: &mut State) -> ResultType<()> {
|
||||
if let Some((_, _, errors)) = &state.stream {
|
||||
if errors.needs_restart() {
|
||||
// Recreate on the service thread, outside the backend's error callback.
|
||||
log::warn!("Recreating interrupted audio capture stream");
|
||||
super::restart();
|
||||
}
|
||||
}
|
||||
if !RESTARTING.load(Ordering::SeqCst) {
|
||||
run_serv_snapshot(sp, state)
|
||||
} else {
|
||||
@@ -353,7 +367,9 @@ mod cpal_impl {
|
||||
Ok((device, format))
|
||||
}
|
||||
|
||||
fn play(sp: &GenericService) -> ResultType<(Box<dyn StreamTrait>, Arc<Message>)> {
|
||||
fn play(
|
||||
sp: &GenericService,
|
||||
) -> ResultType<(Box<dyn StreamTrait>, Arc<Message>, CaptureErrorHandler)> {
|
||||
use cpal::SampleFormat::*;
|
||||
let (device, config) = get_device()?;
|
||||
let sp = sp.clone();
|
||||
@@ -371,7 +387,7 @@ mod cpal_impl {
|
||||
48000
|
||||
};
|
||||
let ch = if config.channels() > 1 { Stereo } else { Mono };
|
||||
let stream = match config.sample_format() {
|
||||
let (stream, errors) = match config.sample_format() {
|
||||
I8 => build_input_stream::<i8>(device, &config, sp, sample_rate, ch)?,
|
||||
I16 => build_input_stream::<i16>(device, &config, sp, sample_rate, ch)?,
|
||||
I32 => build_input_stream::<i32>(device, &config, sp, sample_rate, ch)?,
|
||||
@@ -385,9 +401,12 @@ mod cpal_impl {
|
||||
f => bail!("unsupported audio format: {:?}", f),
|
||||
};
|
||||
stream.play()?;
|
||||
#[cfg(target_os = "macos")]
|
||||
log::info!("Audio capture start call succeeded");
|
||||
Ok((
|
||||
Box::new(stream),
|
||||
Arc::new(create_format_msg(sample_rate, ch as _)),
|
||||
errors,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -397,14 +416,15 @@ mod cpal_impl {
|
||||
sp: GenericService,
|
||||
sample_rate: u32,
|
||||
encode_channel: magnum_opus::Channels,
|
||||
) -> ResultType<cpal::Stream>
|
||||
) -> ResultType<(cpal::Stream, CaptureErrorHandler)>
|
||||
where
|
||||
T: cpal::SizedSample + dasp::sample::ToSample<f32>,
|
||||
{
|
||||
let err_fn = move |err| {
|
||||
// too many UnknownErrno, will improve later
|
||||
log::trace!("an error occurred on stream: {}", err);
|
||||
};
|
||||
let errors = CaptureErrorHandler::default();
|
||||
let callback_errors = errors.clone();
|
||||
let err_fn = move |err| callback_errors.handle(err);
|
||||
#[cfg(target_os = "macos")]
|
||||
let (mut received_samples, mut received_signal) = (false, false);
|
||||
let sample_rate_0 = config.sample_rate().0;
|
||||
log::debug!("Audio sample rate : {}", sample_rate);
|
||||
unsafe {
|
||||
@@ -431,6 +451,25 @@ mod cpal_impl {
|
||||
&stream_config,
|
||||
move |data: &[T], _: &InputCallbackInfo| {
|
||||
let buffer: Vec<f32> = data.iter().map(|s| T::to_sample(*s)).collect();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Starting capture does not guarantee sample delivery or audible data.
|
||||
if !received_samples && !buffer.is_empty() {
|
||||
received_samples = true;
|
||||
log::info!(
|
||||
"Audio capture received first PCM block: {} samples",
|
||||
buffer.len()
|
||||
);
|
||||
}
|
||||
if !received_signal
|
||||
&& buffer
|
||||
.iter()
|
||||
.any(|sample| sample.is_finite() && *sample != 0.0)
|
||||
{
|
||||
received_signal = true;
|
||||
log::info!("Audio capture received first nonzero PCM");
|
||||
}
|
||||
}
|
||||
let mut lock = INPUT_BUFFER.lock().unwrap();
|
||||
lock.extend(buffer);
|
||||
while lock.len() >= rechannel_len {
|
||||
@@ -449,7 +488,7 @@ mod cpal_impl {
|
||||
err_fn,
|
||||
timeout,
|
||||
)?;
|
||||
Ok(stream)
|
||||
Ok((stream, errors))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -299,6 +299,12 @@ 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>,
|
||||
@@ -504,6 +510,9 @@ 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,
|
||||
@@ -950,10 +959,17 @@ 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;
|
||||
@@ -1040,6 +1056,21 @@ 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();
|
||||
@@ -5097,7 +5128,11 @@ impl Connection {
|
||||
// But it's not necessary now and we have to consider two audio services(client, server).
|
||||
crate::audio_service::set_voice_call_input_device(None, true);
|
||||
log::info!("#{} Connection closed: {}", self.inner.id(), reason);
|
||||
if lock && self.lock_after_session_end && self.keyboard {
|
||||
if lock
|
||||
&& self.lock_after_session_end
|
||||
&& self.keyboard
|
||||
&& !raii::AuthedConnID::session_reconnected(self.inner.id(), &self.session_key())
|
||||
{
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
lock_screen().await;
|
||||
}
|
||||
@@ -6658,6 +6693,21 @@ mod raii {
|
||||
pub struct AuthedConnID(i32, AuthConnType);
|
||||
|
||||
impl AuthedConnID {
|
||||
pub(super) fn is_newer_session_remote(c: &AuthedConn, id: i32, key: &SessionKey) -> bool {
|
||||
c.conn_id > id && c.conn_type == AuthConnType::Remote && &c.session_key == key
|
||||
}
|
||||
|
||||
/// Whether a newer remote control connection of this session has replaced this one. A
|
||||
/// controlling peer whose link dies reconnects while the connection it left behind runs
|
||||
/// on here until its own timeout; locking for that one would lock a session that has
|
||||
/// already resumed on its replacement.
|
||||
pub fn session_reconnected(id: i32, key: &SessionKey) -> bool {
|
||||
let conns = AUTHED_CONNS.lock().unwrap();
|
||||
conns
|
||||
.iter()
|
||||
.any(|c| Self::is_newer_session_remote(c, id, key))
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
conn_id: i32,
|
||||
conn_type: AuthConnType,
|
||||
@@ -7547,4 +7597,38 @@ mod test {
|
||||
Ok(BoolOption::NotSet)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn only_a_newer_remote_control_of_the_same_session_keeps_the_screen_unlocked() {
|
||||
let replaced_by = super::raii::AuthedConnID::is_newer_session_remote;
|
||||
|
||||
let key = |session_id, peer: &str| SessionKey {
|
||||
peer_id: peer.to_owned(),
|
||||
name: "".to_owned(),
|
||||
session_id,
|
||||
};
|
||||
let conn = |conn_id, conn_type, session_key| AuthedConn {
|
||||
conn_id,
|
||||
conn_type,
|
||||
session_key,
|
||||
sender: mpsc::unbounded_channel().0,
|
||||
printer: false,
|
||||
};
|
||||
let mine = key(7, "peer");
|
||||
let remote = AuthConnType::Remote;
|
||||
|
||||
assert!(replaced_by(&conn(3, remote, mine.clone()), 2, &mine));
|
||||
// An older one, and itself: of connections ending at once only the last still locks.
|
||||
assert!(!replaced_by(&conn(1, remote, mine.clone()), 2, &mine));
|
||||
assert!(!replaced_by(&conn(2, remote, mine.clone()), 2, &mine));
|
||||
// A kind that keeps no screen in use.
|
||||
assert!(!replaced_by(
|
||||
&conn(3, AuthConnType::Terminal, mine.clone()),
|
||||
2,
|
||||
&mine
|
||||
));
|
||||
// Another session of this peer, and another peer on the same session id: `SessionKey`
|
||||
// is all three fields, and either of those is someone else's screen to lock.
|
||||
assert!(!replaced_by(&conn(3, remote, key(8, "peer")), 2, &mine));
|
||||
assert!(!replaced_by(&conn(3, remote, key(7, "other")), 2, &mine));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,24 +7,44 @@ use std::{
|
||||
|
||||
/*
|
||||
FPS adjust:
|
||||
a. new user connected =>set to INIT_FPS
|
||||
b. TestDelay receive => update user's fps according to network delay
|
||||
When network delay < DELAY_THRESHOLD_150MS, set minimum fps according to image quality, and increase fps;
|
||||
When network delay >= DELAY_THRESHOLD_150MS, set minimum fps according to image quality, and decrease fps;
|
||||
c. second timeout / TestDelay receive => update real fps to the minimum fps from all users
|
||||
a. new user connected => set to INIT_FPS
|
||||
b. TestDelay reply => update the user's fps from the excess delay, the reply's delay
|
||||
above the baseline this connection has shown so far:
|
||||
startup: two consecutive replies with excess < 50 ms permit doubling toward
|
||||
the viewer's cap; a higher excess or a brake ends this acceleration;
|
||||
excess < DELAY_THRESHOLD_150MS: a good reply; grows the fps, and after a
|
||||
reduction returns to the level held before it after two good replies;
|
||||
excess >= DELAY_THRESHOLD_150MS: a bad reply; nothing happens until three in a
|
||||
row confirm congestion, including after each reduction. FPS drops by a
|
||||
fifth at most; a second of excess cannot wait and halves it immediately.
|
||||
A recent fast restore also permits halving at 600 ms of excess.
|
||||
While the bitrate can still be reduced (ABR) it is reduced first and the fps keeps
|
||||
a floor: bitrate-targeted encoders do not send fewer bytes at fewer frames.
|
||||
c. probe outstanding for more than two seconds => halve the fps for every further
|
||||
second, down to MIN_AUTO_FPS and never above the target it found; the late
|
||||
reply does not reduce again. Automatic reductions respect this floor unless
|
||||
the viewer requested a lower FPS cap.
|
||||
d. second timeout / TestDelay reply => real fps is the minimum over all users;
|
||||
every user starts at INIT_FPS, adapts from its own target and is capped by its
|
||||
own limit, never by that minimum or by another user's limit
|
||||
|
||||
ratio adjust:
|
||||
a. user set image quality => update to the maximum ratio of the latest quality
|
||||
b. 3 seconds timeout => update ratio according to network delay
|
||||
When network delay < DELAY_THRESHOLD_150MS, increase ratio, max 150kbps;
|
||||
When network delay >= DELAY_THRESHOLD_150MS, decrease ratio;
|
||||
|
||||
adjust between FPS and ratio:
|
||||
When network delay < DELAY_THRESHOLD_150MS, fps is always higher than the minimum fps, and ratio is increasing;
|
||||
When network delay >= DELAY_THRESHOLD_150MS, fps is always lower than the minimum fps, and ratio is decreasing;
|
||||
When a user calls for a reduction (two bad replies in a row, or a probe still
|
||||
out at the second tick past two seconds), decrease ratio by the step that user's
|
||||
own delay and confirmation call for, the most conservative step over all users;
|
||||
one slow reply or one short stall does not, and one user's spike is never paired
|
||||
with another user's confirmation.
|
||||
c. confirmed congestion => decrease ratio at once, when the 3 seconds cooldown allows
|
||||
|
||||
delay:
|
||||
use delay minus RTT as the actual network delay
|
||||
TestDelay shares the video stream, so it measures the queue in front of it rather
|
||||
than the path RTT. The baseline starts at the first reply and follows lower
|
||||
delays immediately. Old minima expire after 20 fresh replies; a higher window
|
||||
minimum is learned gradually only when the recent floor is no longer rising.
|
||||
Outstanding-probe checks and their late replies do not age this window.
|
||||
*/
|
||||
|
||||
// Constants
|
||||
@@ -32,6 +52,7 @@ pub const FPS: u32 = 30;
|
||||
pub const MIN_FPS: u32 = 1;
|
||||
pub const MAX_FPS: u32 = 120;
|
||||
pub const INIT_FPS: u32 = 15;
|
||||
const MIN_AUTO_FPS: u32 = 5;
|
||||
|
||||
// Bitrate ratio constants for different quality levels
|
||||
const BR_MAX: f32 = 40.0; // 2000 * 2 / 100
|
||||
@@ -43,45 +64,196 @@ const HISTORY_DELAY_LEN: usize = 2;
|
||||
const ADJUST_RATIO_INTERVAL: usize = 3; // Adjust quality ratio every 3 seconds
|
||||
const DYNAMIC_SCREEN_THRESHOLD: usize = 2; // Allow increase quality ratio if encode more than 2 times in one second
|
||||
const DELAY_THRESHOLD_150MS: u32 = 150; // 150ms is the threshold for good network condition
|
||||
const RESTORE_GUARD_SAMPLES: u8 = 5; // A restored level that congests this soon is lowered
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
struct UserDelay {
|
||||
response_delayed: bool,
|
||||
stall_ticks: u8, // timer ticks the outstanding probe has been out beyond two seconds
|
||||
delay_history: VecDeque<u32>,
|
||||
fps: Option<u32>,
|
||||
rtt_calculator: RttCalculator,
|
||||
quick_increase_fps_count: usize,
|
||||
increase_fps_count: usize,
|
||||
consecutive_bad_samples: usize,
|
||||
fps_bad_samples: u8, // fresh bad replies since the last FPS reduction
|
||||
good_samples: usize, // since the last reduction, capped at 3
|
||||
replies_after_bitrate_reduction: Option<u8>,
|
||||
fps_before_congestion: Option<u32>, // level to return to once replies are good again
|
||||
samples_since_restore: Option<u8>, // set by a restore, cleared once it proved stable
|
||||
stall_reference_fps: Option<u32>, // fps when the outstanding probe passed two seconds
|
||||
startup_good_samples: u8, // u8::MAX permanently ends startup acceleration
|
||||
}
|
||||
|
||||
impl UserDelay {
|
||||
fn add_delay(&mut self, delay: u32) {
|
||||
self.rtt_calculator.update(delay);
|
||||
if self.delay_history.len() > HISTORY_DELAY_LEN {
|
||||
if self.delay_history.len() >= HISTORY_DELAY_LEN {
|
||||
self.delay_history.pop_front();
|
||||
}
|
||||
self.delay_history.push_back(delay);
|
||||
}
|
||||
|
||||
// Average delay minus RTT
|
||||
fn avg_delay(&self) -> u32 {
|
||||
let len = self.delay_history.len();
|
||||
if len > 0 {
|
||||
let avg_delay = self.delay_history.iter().sum::<u32>() / len as u32;
|
||||
|
||||
// If RTT is available, subtract it from average delay to get actual network latency
|
||||
if let Some(rtt) = self.rtt_calculator.get_rtt() {
|
||||
if avg_delay > rtt {
|
||||
avg_delay - rtt
|
||||
} else {
|
||||
avg_delay
|
||||
}
|
||||
} else {
|
||||
avg_delay
|
||||
}
|
||||
} else {
|
||||
DELAY_THRESHOLD_150MS
|
||||
fn limit_fps_change(
|
||||
&mut self,
|
||||
current_fps: u32,
|
||||
fps: u32,
|
||||
delay: u32,
|
||||
bitrate_first: bool,
|
||||
braked: bool,
|
||||
) -> u32 {
|
||||
// A spike stays in the average for several samples; confirm congestion with fresh samples.
|
||||
let delay = delay.saturating_sub(self.rtt_calculator.get_rtt().unwrap_or_default());
|
||||
if let Some(samples) = self.samples_since_restore.as_mut() {
|
||||
*samples = samples.saturating_add(1);
|
||||
}
|
||||
if delay < DELAY_THRESHOLD_150MS {
|
||||
self.consecutive_bad_samples = 0;
|
||||
self.fps_bad_samples = 0;
|
||||
self.replies_after_bitrate_reduction = None;
|
||||
self.good_samples = (self.good_samples + 1).min(3);
|
||||
return self.recover(current_fps, fps);
|
||||
}
|
||||
self.consecutive_bad_samples = (self.consecutive_bad_samples + 1).min(3);
|
||||
self.fps_bad_samples = (self.fps_bad_samples + 1).min(3);
|
||||
if let Some(replies) = self.replies_after_bitrate_reduction.as_mut() {
|
||||
*replies = (*replies + 1).min(2);
|
||||
}
|
||||
let failed_restore = delay >= 600
|
||||
&& self
|
||||
.samples_since_restore
|
||||
.is_some_and(|samples| samples <= RESTORE_GUARD_SAMPLES);
|
||||
// A level that congests right after being restored is not the level to return to.
|
||||
if self
|
||||
.samples_since_restore
|
||||
.is_some_and(|samples| samples <= RESTORE_GUARD_SAMPLES)
|
||||
&& (failed_restore || self.consecutive_bad_samples >= 3)
|
||||
{
|
||||
self.fps_before_congestion = Some(current_fps - current_fps / 4);
|
||||
self.samples_since_restore = None;
|
||||
}
|
||||
// The timeout brake already reduced for the probe this reply answers.
|
||||
if fps >= current_fps || braked {
|
||||
return current_fps;
|
||||
}
|
||||
// An extra second of delay cannot wait for another confirmation.
|
||||
if !failed_restore
|
||||
&& delay < 1000
|
||||
&& (self.fps_bad_samples < 3
|
||||
|| (bitrate_first && self.replies_after_bitrate_reduction.unwrap_or_default() < 2))
|
||||
{
|
||||
return current_fps;
|
||||
}
|
||||
// A fast restore probes capacity. Roll it back promptly if the queue grows
|
||||
// again, rather than waiting through another ordinary confirmation window.
|
||||
let divisor = if delay >= 1000 || failed_restore {
|
||||
2
|
||||
} else {
|
||||
5
|
||||
};
|
||||
self.on_reduction(current_fps);
|
||||
fps.max(current_fps.saturating_sub((current_fps / divisor).max(1)))
|
||||
}
|
||||
|
||||
// Fresh low-delay replies permit recovery even while the average contains a spike:
|
||||
// a little at first, then back to the level held before congestion.
|
||||
fn recover(&mut self, current_fps: u32, fps: u32) -> u32 {
|
||||
let gradual = current_fps + (current_fps / 10).max(1);
|
||||
let level = self
|
||||
.fps_before_congestion
|
||||
.filter(|level| *level > current_fps);
|
||||
match (self.good_samples, level) {
|
||||
(2 | 3, Some(level)) => {
|
||||
self.fps_before_congestion = None;
|
||||
self.samples_since_restore = Some(0);
|
||||
fps.max(level)
|
||||
}
|
||||
(3, None) => {
|
||||
self.fps_before_congestion = None;
|
||||
fps.max(current_fps + (current_fps / 5).max(2))
|
||||
}
|
||||
_ => gradual,
|
||||
}
|
||||
}
|
||||
|
||||
fn accelerate_startup(
|
||||
&mut self,
|
||||
current_fps: u32,
|
||||
fps: u32,
|
||||
cap: u32,
|
||||
delay: u32,
|
||||
braked: bool,
|
||||
) -> u32 {
|
||||
if self.startup_good_samples == u8::MAX {
|
||||
return fps;
|
||||
}
|
||||
let excess = delay.saturating_sub(self.rtt_calculator.get_rtt().unwrap_or_default());
|
||||
// A low-load sample does not establish capacity: require two clean replies
|
||||
// per step and abandon startup probing on the first sign of queue growth.
|
||||
if braked || excess >= 50 || current_fps >= cap {
|
||||
self.startup_good_samples = u8::MAX;
|
||||
return fps;
|
||||
}
|
||||
self.startup_good_samples += 1;
|
||||
if self.startup_good_samples < 2 {
|
||||
return fps;
|
||||
}
|
||||
self.startup_good_samples = 0;
|
||||
let accelerated = fps.max(current_fps.saturating_mul(2)).min(cap);
|
||||
if accelerated >= cap {
|
||||
self.startup_good_samples = u8::MAX;
|
||||
}
|
||||
accelerated
|
||||
}
|
||||
|
||||
// The first reduction of an episode remembers the level to return to.
|
||||
fn on_reduction(&mut self, current_fps: u32) {
|
||||
self.startup_good_samples = u8::MAX;
|
||||
self.good_samples = 0;
|
||||
self.fps_bad_samples = 0;
|
||||
if self.fps_before_congestion.is_none() {
|
||||
self.fps_before_congestion = Some(current_fps);
|
||||
}
|
||||
}
|
||||
|
||||
// Bitrate is cut on confirmation only: two bad replies in a row, or a probe still
|
||||
// outstanding at the second tick past two seconds. One slow reply or one short
|
||||
// stall is jitter, and a static screen would never earn the cut back.
|
||||
fn needs_bitrate_reduction(&self) -> bool {
|
||||
self.consecutive_bad_samples >= 2 || self.stall_ticks >= 2
|
||||
}
|
||||
|
||||
// The bitrate step this viewer's own evidence calls for, None when it calls for
|
||||
// none. Severity and confirmation come from the same viewer; the controller
|
||||
// never pairs one viewer's spike with another viewer's confirmation.
|
||||
fn ratio_reduction(&self) -> Option<f32> {
|
||||
if !self.needs_bitrate_reduction() {
|
||||
return None;
|
||||
}
|
||||
let excess = self.avg_delay();
|
||||
let confirmed = self.consecutive_bad_samples >= 3;
|
||||
Some(if excess < 200 {
|
||||
0.95
|
||||
} else if excess < 300 {
|
||||
0.9
|
||||
} else if excess < 500 {
|
||||
if confirmed {
|
||||
0.7
|
||||
} else {
|
||||
0.85
|
||||
}
|
||||
} else if confirmed {
|
||||
0.5
|
||||
} else {
|
||||
0.8
|
||||
})
|
||||
}
|
||||
|
||||
// Average delay above the baseline: what the queue adds on top of the path itself.
|
||||
fn avg_delay(&self) -> u32 {
|
||||
if self.delay_history.is_empty() {
|
||||
return DELAY_THRESHOLD_150MS;
|
||||
}
|
||||
let avg_delay = self.delay_history.iter().sum::<u32>() / self.delay_history.len() as u32;
|
||||
avg_delay.saturating_sub(self.rtt_calculator.get_rtt().unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +265,20 @@ struct UserData {
|
||||
quality: Option<(i64, Quality)>, // (time, quality)
|
||||
delay: UserDelay,
|
||||
record: bool,
|
||||
joined_at: Option<Instant>, // set by on_connection_open; the start-up guard's clock
|
||||
}
|
||||
|
||||
impl UserData {
|
||||
// The frame rate this viewer asked for, from its custom or auto-adjust limit.
|
||||
fn fps_cap(&self) -> u32 {
|
||||
let mut fps = self.custom_fps.unwrap_or(FPS);
|
||||
if let Some(auto_adjust_fps) = self.auto_adjust_fps {
|
||||
if fps == 0 || auto_adjust_fps < fps {
|
||||
fps = auto_adjust_fps;
|
||||
}
|
||||
}
|
||||
fps.clamp(MIN_FPS, MAX_FPS)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
@@ -110,7 +296,9 @@ pub struct VideoQoS {
|
||||
bitrate_store: u32,
|
||||
adjust_ratio_instant: Instant,
|
||||
abr_config: bool,
|
||||
new_user_instant: Instant,
|
||||
first_reply_adjusts_ratio: bool, // false on Linux, where it can create vaapi twice
|
||||
#[cfg(test)]
|
||||
test_now: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for VideoQoS {
|
||||
@@ -123,11 +311,33 @@ impl Default for VideoQoS {
|
||||
bitrate_store: 0,
|
||||
adjust_ratio_instant: Instant::now(),
|
||||
abr_config: true,
|
||||
new_user_instant: Instant::now(),
|
||||
first_reply_adjusts_ratio: !cfg!(target_os = "linux"),
|
||||
#[cfg(test)]
|
||||
test_now: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clock; tests drive a virtual clock so timing is deterministic.
|
||||
impl VideoQoS {
|
||||
fn now(&self) -> Instant {
|
||||
#[cfg(test)]
|
||||
if let Some(now) = self.test_now {
|
||||
return now;
|
||||
}
|
||||
Instant::now()
|
||||
}
|
||||
|
||||
fn since(&self, instant: Instant) -> Duration {
|
||||
self.now().saturating_duration_since(instant)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn advance_ms(&mut self, ms: u64) {
|
||||
self.test_now = Some(self.now() + Duration::from_millis(ms));
|
||||
}
|
||||
}
|
||||
|
||||
// Basic functionality
|
||||
impl VideoQoS {
|
||||
// Calculate seconds per frame based on current FPS
|
||||
@@ -184,9 +394,12 @@ impl VideoQoS {
|
||||
impl VideoQoS {
|
||||
// Initialize new user session
|
||||
pub fn on_connection_open(&mut self, id: i32) {
|
||||
self.users.insert(id, UserData::default());
|
||||
let user = UserData {
|
||||
joined_at: Some(self.now()),
|
||||
..Default::default()
|
||||
};
|
||||
self.users.insert(id, user);
|
||||
self.abr_config = Config::get_option("enable-abr") != "N";
|
||||
self.new_user_instant = Instant::now();
|
||||
}
|
||||
|
||||
// Clean up user session
|
||||
@@ -194,7 +407,11 @@ impl VideoQoS {
|
||||
self.users.remove(&id);
|
||||
if self.users.is_empty() {
|
||||
*self = Default::default();
|
||||
return;
|
||||
}
|
||||
// The stream follows the remaining viewers at once; a departed viewer's
|
||||
// start-up guard left with its entry.
|
||||
self.adjust_fps();
|
||||
}
|
||||
|
||||
pub fn user_custom_fps(&mut self, id: i32, fps: u32) {
|
||||
@@ -244,8 +461,10 @@ impl VideoQoS {
|
||||
}
|
||||
|
||||
pub fn user_network_delay(&mut self, id: i32, delay: u32) {
|
||||
let highest_fps = self.highest_fps();
|
||||
let target_ratio = self.latest_quality().ratio();
|
||||
// Fewer frames only save bytes with encoders that size frames for a fixed rate;
|
||||
// bitrate-targeted encoders keep the bitrate, so the bitrate has to come down first.
|
||||
let bitrate_first = self.can_reduce_bitrate();
|
||||
|
||||
// For bad network, small fps means quick reaction and high quality
|
||||
let (min_fps, normal_fps) = if target_ratio >= BR_BEST {
|
||||
@@ -260,13 +479,25 @@ impl VideoQoS {
|
||||
let dividend_ms = DELAY_THRESHOLD_150MS * min_fps;
|
||||
|
||||
let mut adjust_ratio = false;
|
||||
let mut reduce_bitrate = false;
|
||||
if let Some(user) = self.users.get_mut(&id) {
|
||||
let delay = delay.max(10);
|
||||
// The reply closes the outstanding probe, braked or not.
|
||||
user.delay.stall_ticks = 0;
|
||||
let braked = user.delay.stall_reference_fps.take().is_some();
|
||||
let old_avg_delay = user.delay.avg_delay();
|
||||
if !braked {
|
||||
user.delay.rtt_calculator.update(delay);
|
||||
}
|
||||
user.delay.add_delay(delay);
|
||||
let mut avg_delay = user.delay.avg_delay();
|
||||
avg_delay = avg_delay.max(10);
|
||||
let mut fps = self.fps;
|
||||
// Each viewer adapts from its own target, starts at INIT_FPS and is capped
|
||||
// by its own limit. The stream follows the slowest viewer in adjust_fps;
|
||||
// neither that minimum nor another viewer's limit feeds back into it.
|
||||
let user_cap = user.fps_cap();
|
||||
let current_fps = user.delay.fps.unwrap_or(INIT_FPS.min(user_cap));
|
||||
let mut fps = current_fps;
|
||||
|
||||
// Adaptive FPS adjustment based on network delay:
|
||||
if avg_delay < 50 {
|
||||
@@ -321,26 +552,84 @@ impl VideoQoS {
|
||||
user.delay.quick_increase_fps_count = 0;
|
||||
}
|
||||
|
||||
fps = fps.clamp(MIN_FPS, highest_fps);
|
||||
if bitrate_first {
|
||||
// While the bitrate can still come down, the frame rate keeps its floor.
|
||||
fps = fps.max(min_fps);
|
||||
}
|
||||
fps = fps.max(MIN_AUTO_FPS.min(user_cap));
|
||||
fps = user
|
||||
.delay
|
||||
.limit_fps_change(current_fps, fps, delay, bitrate_first, braked);
|
||||
fps = user
|
||||
.delay
|
||||
.accelerate_startup(current_fps, fps, user_cap, delay, braked);
|
||||
reduce_bitrate = bitrate_first
|
||||
&& user.delay.needs_bitrate_reduction()
|
||||
&& user.delay.replies_after_bitrate_reduction.is_none();
|
||||
fps = fps.clamp(MIN_FPS, user_cap);
|
||||
// first network delay message
|
||||
adjust_ratio = user.delay.fps.is_none();
|
||||
user.delay.fps = Some(fps);
|
||||
let base = user.delay.rtt_calculator.get_rtt().unwrap_or_default();
|
||||
log::debug!(
|
||||
"qos_trace t={} id={id} delay={delay} base={base} excess={} avg={avg_delay} bad={} good={} braked={braked} fps={fps} ratio={:.3} reduce_bitrate={reduce_bitrate}",
|
||||
hbb_common::get_time(),
|
||||
delay.saturating_sub(base),
|
||||
user.delay.consecutive_bad_samples,
|
||||
user.delay.good_samples,
|
||||
self.ratio,
|
||||
);
|
||||
}
|
||||
self.adjust_fps();
|
||||
if adjust_ratio && !cfg!(target_os = "linux") {
|
||||
//Reduce the possibility of vaapi being created twice
|
||||
// A viewer's first reply is one more trigger of the periodic adjustment and
|
||||
// keeps its cooldown: a viewer joining right after a cut must not spend the
|
||||
// other viewers' evidence a second time.
|
||||
if adjust_ratio
|
||||
&& self.first_reply_adjusts_ratio
|
||||
&& self.since(self.adjust_ratio_instant).as_secs() >= ADJUST_RATIO_INTERVAL as u64
|
||||
{
|
||||
self.adjust_ratio(false);
|
||||
}
|
||||
if reduce_bitrate
|
||||
&& self.since(self.adjust_ratio_instant).as_secs() >= ADJUST_RATIO_INTERVAL as u64
|
||||
{
|
||||
self.adjust_ratio(false);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_delay_response_elapsed(&mut self, id: i32, elapsed: u128) {
|
||||
if let Some(user) = self.users.get_mut(&id) {
|
||||
user.delay.response_delayed = elapsed > 2000;
|
||||
if user.delay.response_delayed {
|
||||
user.delay.add_delay(elapsed as u32);
|
||||
self.adjust_fps();
|
||||
}
|
||||
let Some(user) = self.users.get_mut(&id) else {
|
||||
return;
|
||||
};
|
||||
if elapsed <= 2000 {
|
||||
return;
|
||||
}
|
||||
user.delay.stall_ticks = user.delay.stall_ticks.saturating_add(1);
|
||||
user.delay.add_delay(elapsed as u32);
|
||||
// Halve for every second the probe stays out beyond the first: two seconds
|
||||
// halve, three quarter, and so on down to the floor.
|
||||
let reference = match user.delay.stall_reference_fps {
|
||||
Some(reference) => reference,
|
||||
None => {
|
||||
let reference = user.delay.fps.unwrap_or(INIT_FPS.min(user.fps_cap()));
|
||||
user.delay.stall_reference_fps = Some(reference);
|
||||
user.delay.on_reduction(reference);
|
||||
reference
|
||||
}
|
||||
};
|
||||
let divisor = 1u32 << ((elapsed / 1000) as u32).saturating_sub(1).min(5);
|
||||
let user_cap = user.fps_cap();
|
||||
// The floor is a floor, not a lift: a target already below it stays.
|
||||
let current = user.delay.fps.unwrap_or(reference);
|
||||
let fps = (reference / divisor)
|
||||
.clamp(MIN_AUTO_FPS.min(user_cap), user_cap)
|
||||
.min(current);
|
||||
user.delay.fps = Some(fps);
|
||||
log::debug!(
|
||||
"qos_trace t={} id={id} timeout={elapsed} fps={fps}",
|
||||
hbb_common::get_time()
|
||||
);
|
||||
self.adjust_fps();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,14 +651,11 @@ impl VideoQoS {
|
||||
self.adjust_fps();
|
||||
let abr_enabled = self.in_vbr_state();
|
||||
if abr_enabled {
|
||||
if self.adjust_ratio_instant.elapsed().as_secs() >= ADJUST_RATIO_INTERVAL as u64 {
|
||||
if self.since(self.adjust_ratio_instant).as_secs() >= ADJUST_RATIO_INTERVAL as u64 {
|
||||
let dynamic_screen = self
|
||||
.displays
|
||||
.iter()
|
||||
.any(|d| d.1.send_counter >= ADJUST_RATIO_INTERVAL * DYNAMIC_SCREEN_THRESHOLD);
|
||||
self.displays.iter_mut().for_each(|d| {
|
||||
d.1.send_counter = 0;
|
||||
});
|
||||
self.adjust_ratio(dynamic_screen);
|
||||
}
|
||||
} else {
|
||||
@@ -379,25 +665,12 @@ impl VideoQoS {
|
||||
|
||||
#[inline]
|
||||
fn highest_fps(&self) -> u32 {
|
||||
let user_fps = |u: &UserData| {
|
||||
let mut fps = u.custom_fps.unwrap_or(FPS);
|
||||
if let Some(auto_adjust_fps) = u.auto_adjust_fps {
|
||||
if fps == 0 || auto_adjust_fps < fps {
|
||||
fps = auto_adjust_fps;
|
||||
}
|
||||
}
|
||||
fps
|
||||
};
|
||||
|
||||
let fps = self
|
||||
.users
|
||||
.iter()
|
||||
.map(|(_, u)| user_fps(u))
|
||||
.filter(|u| *u >= MIN_FPS)
|
||||
self.users
|
||||
.values()
|
||||
.map(|u| u.fps_cap())
|
||||
.min()
|
||||
.unwrap_or(FPS);
|
||||
|
||||
fps.clamp(MIN_FPS, MAX_FPS)
|
||||
.unwrap_or(FPS)
|
||||
.clamp(MIN_FPS, MAX_FPS)
|
||||
}
|
||||
|
||||
// Get latest quality settings from all users
|
||||
@@ -412,40 +685,16 @@ impl VideoQoS {
|
||||
.1
|
||||
}
|
||||
|
||||
// Adjust quality ratio based on network delay and screen changes
|
||||
fn adjust_ratio(&mut self, dynamic_screen: bool) {
|
||||
if !self.in_vbr_state() {
|
||||
return;
|
||||
}
|
||||
// Get maximum delay from all users
|
||||
let max_delay = self.users.iter().map(|u| u.1.delay.avg_delay()).max();
|
||||
let Some(max_delay) = max_delay else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target_quality = self.latest_quality();
|
||||
let target_ratio = self.latest_quality().ratio();
|
||||
let current_ratio = self.ratio;
|
||||
// Lowest ratio the latest quality allows: keeps about 1Mbps at high resolutions.
|
||||
fn min_ratio(&self) -> f32 {
|
||||
let current_bitrate = self.bitrate();
|
||||
|
||||
// Calculate minimum ratio for high resolution (1Mbps baseline)
|
||||
let ratio_1mbps = if current_bitrate > 0 {
|
||||
Some((current_ratio * 1000.0 / current_bitrate as f32).max(BR_MIN_HIGH_RESOLUTION))
|
||||
Some((self.ratio * 1000.0 / current_bitrate as f32).max(BR_MIN_HIGH_RESOLUTION))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Calculate ratio for adding 150kbps bandwidth
|
||||
let ratio_add_150kbps = if current_bitrate > 0 {
|
||||
Some((current_bitrate + 150) as f32 * current_ratio / current_bitrate as f32)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Set minimum ratio based on quality mode
|
||||
let min = match target_quality {
|
||||
match self.latest_quality() {
|
||||
Quality::Best => {
|
||||
// For Best quality, ensure minimum 1Mbps for high resolution
|
||||
let mut min = BR_BEST / 2.5;
|
||||
if let Some(ratio_1mbps) = ratio_1mbps {
|
||||
if min > ratio_1mbps {
|
||||
@@ -463,15 +712,67 @@ impl VideoQoS {
|
||||
}
|
||||
min.max(BR_MIN_HIGH_RESOLUTION)
|
||||
}
|
||||
Quality::Low => BR_MIN_HIGH_RESOLUTION,
|
||||
Quality::Custom(_) => BR_MIN_HIGH_RESOLUTION,
|
||||
Quality::Low | Quality::Custom(_) => BR_MIN_HIGH_RESOLUTION,
|
||||
}
|
||||
}
|
||||
|
||||
// Whether congestion can still be answered with a lower bitrate. Within two
|
||||
// percent of the floor another step is not worth waiting a cooldown for.
|
||||
fn can_reduce_bitrate(&self) -> bool {
|
||||
self.in_vbr_state() && !self.displays.is_empty() && self.ratio > self.min_ratio() * 1.02
|
||||
}
|
||||
|
||||
// Every ratio adjustment starts a new window for the dynamic screen counters.
|
||||
fn reset_send_counters(&mut self) {
|
||||
self.displays.values_mut().for_each(|d| d.send_counter = 0);
|
||||
}
|
||||
|
||||
// Adjust quality ratio based on network delay and screen changes
|
||||
fn adjust_ratio(&mut self, dynamic_screen: bool) {
|
||||
if !self.in_vbr_state() {
|
||||
return;
|
||||
}
|
||||
// Get maximum delay from all users
|
||||
let max_delay = self.users.iter().map(|u| u.1.delay.avg_delay()).max();
|
||||
let Some(max_delay) = max_delay else {
|
||||
return;
|
||||
};
|
||||
// Each viewer judges its own delay; the stream takes the most conservative
|
||||
// step any viewer asks for.
|
||||
let reduction = self
|
||||
.users
|
||||
.values()
|
||||
.filter_map(|u| u.delay.ratio_reduction())
|
||||
.reduce(f32::min);
|
||||
if reduction.is_none() && max_delay >= DELAY_THRESHOLD_150MS {
|
||||
// Elevated but unconfirmed: no change, and no cooldown either, so a
|
||||
// confirmation on the next reply is acted on at once.
|
||||
self.reset_send_counters();
|
||||
return;
|
||||
}
|
||||
|
||||
let target_ratio = self.latest_quality().ratio();
|
||||
let current_ratio = self.ratio;
|
||||
let current_bitrate = self.bitrate();
|
||||
|
||||
// Calculate ratio for adding 150kbps bandwidth
|
||||
let ratio_add_150kbps = if current_bitrate > 0 {
|
||||
Some((current_bitrate + 150) as f32 * current_ratio / current_bitrate as f32)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let min = self.min_ratio();
|
||||
let max = target_ratio * MAX_BR_MULTIPLE;
|
||||
|
||||
let mut v = current_ratio;
|
||||
|
||||
// Adjust ratio based on network delay thresholds
|
||||
if max_delay < 50 {
|
||||
// Three bad replies in a row confirm congestion; with a bitrate-targeted
|
||||
// encoder the bitrate is then the only thing that drains the queue, so it
|
||||
// comes down hard. Increases need every viewer below the threshold.
|
||||
if let Some(factor) = reduction {
|
||||
v = current_ratio * factor;
|
||||
} else if max_delay < 50 {
|
||||
if dynamic_screen {
|
||||
v = current_ratio * 1.15;
|
||||
}
|
||||
@@ -479,18 +780,8 @@ impl VideoQoS {
|
||||
if dynamic_screen {
|
||||
v = current_ratio * 1.1;
|
||||
}
|
||||
} else if max_delay < DELAY_THRESHOLD_150MS {
|
||||
if dynamic_screen {
|
||||
v = current_ratio * 1.05;
|
||||
}
|
||||
} else if max_delay < 200 {
|
||||
v = current_ratio * 0.95;
|
||||
} else if max_delay < 300 {
|
||||
v = current_ratio * 0.9;
|
||||
} else if max_delay < 500 {
|
||||
v = current_ratio * 0.85;
|
||||
} else {
|
||||
v = current_ratio * 0.8;
|
||||
} else if dynamic_screen {
|
||||
v = current_ratio * 1.05;
|
||||
}
|
||||
|
||||
// Limit quality increase rate for better stability
|
||||
@@ -503,8 +794,24 @@ impl VideoQoS {
|
||||
}
|
||||
}
|
||||
|
||||
if reduction.is_some() {
|
||||
for user in self.users.values_mut() {
|
||||
if user.delay.needs_bitrate_reduction()
|
||||
&& user.delay.replies_after_bitrate_reduction.is_none()
|
||||
{
|
||||
// One outstanding probe may have started before the bitrate change.
|
||||
user.delay.replies_after_bitrate_reduction =
|
||||
Some(if v.clamp(min, max) < current_ratio {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.ratio = v.clamp(min, max);
|
||||
self.adjust_ratio_instant = Instant::now();
|
||||
self.reset_send_counters();
|
||||
self.adjust_ratio_instant = self.now();
|
||||
}
|
||||
|
||||
// Adjust fps based on network delay and user response time
|
||||
@@ -518,17 +825,13 @@ impl VideoQoS {
|
||||
.min()
|
||||
.unwrap_or(INIT_FPS);
|
||||
|
||||
if self.users.iter().any(|u| u.1.delay.response_delayed) {
|
||||
if fps > MIN_FPS + 1 {
|
||||
fps = MIN_FPS + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// For new connections (within 1 second), cap fps to INIT_FPS to ensure stability
|
||||
if self.new_user_instant.elapsed().as_secs() < 1 {
|
||||
if fps > INIT_FPS {
|
||||
fps = INIT_FPS;
|
||||
}
|
||||
// Every viewer inside its first second keeps the stream at INIT_FPS to
|
||||
// ensure stability; each viewer carries its own start-up clock.
|
||||
if self.users.values().any(|u| {
|
||||
u.joined_at
|
||||
.is_some_and(|joined| self.since(joined).as_secs() < 1)
|
||||
}) {
|
||||
fps = fps.min(INIT_FPS);
|
||||
}
|
||||
|
||||
// Ensure fps stays within valid range
|
||||
@@ -538,58 +841,183 @@ impl VideoQoS {
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
struct RttCalculator {
|
||||
min_rtt: Option<u32>, // Historical minimum RTT ever observed
|
||||
window_min_rtt: Option<u32>, // Minimum RTT within last 60 samples
|
||||
smoothed_rtt: Option<u32>, // Smoothed RTT estimation
|
||||
samples: VecDeque<u32>, // Last 60 RTT samples
|
||||
baseline: Option<u32>,
|
||||
samples: VecDeque<u32>,
|
||||
}
|
||||
|
||||
impl RttCalculator {
|
||||
const WINDOW_SAMPLES: usize = 60; // Keep last 60 samples
|
||||
const MIN_SAMPLES: usize = 10; // Require at least 10 samples
|
||||
const ALPHA: f32 = 0.5; // Smoothing factor for weighted average
|
||||
const WINDOW_SAMPLES: usize = 20;
|
||||
const MAX_INCREASE_MS: u32 = 50;
|
||||
|
||||
/// Update RTT estimates with a new sample
|
||||
pub fn update(&mut self, delay: u32) {
|
||||
// 1. Update historical minimum RTT
|
||||
match self.min_rtt {
|
||||
Some(min_rtt) if delay < min_rtt => self.min_rtt = Some(delay),
|
||||
None => self.min_rtt = Some(delay),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 2. Update sample window
|
||||
if self.samples.len() >= Self::WINDOW_SAMPLES {
|
||||
self.samples.pop_front();
|
||||
}
|
||||
self.samples.push_back(delay);
|
||||
let baseline = self.baseline.unwrap_or(delay).min(delay);
|
||||
self.baseline = Some(baseline);
|
||||
|
||||
// 3. Calculate minimum RTT within the window
|
||||
self.window_min_rtt = self.samples.iter().min().copied();
|
||||
|
||||
// 4. Calculate smoothed RTT
|
||||
// Use weighted average if we have enough samples
|
||||
if self.samples.len() >= Self::WINDOW_SAMPLES {
|
||||
if let (Some(min), Some(window_min)) = (self.min_rtt, self.window_min_rtt) {
|
||||
// Weighted average of historical minimum and window minimum
|
||||
let new_srtt =
|
||||
((1.0 - Self::ALPHA) * min as f32 + Self::ALPHA * window_min as f32) as u32;
|
||||
self.smoothed_rtt = Some(new_srtt);
|
||||
}
|
||||
if self.samples.len() < Self::WINDOW_SAMPLES {
|
||||
return;
|
||||
}
|
||||
let half = Self::WINDOW_SAMPLES / 2;
|
||||
let older_min = self.samples.iter().take(half).min().copied();
|
||||
let recent_min = self.samples.iter().skip(half).min().copied();
|
||||
let (Some(older_min), Some(recent_min)) = (older_min, recent_min) else {
|
||||
return;
|
||||
};
|
||||
// A rising floor can be a growing queue. Allow 10 ms of probe granularity,
|
||||
// but wait for it to settle before forgetting the old baseline.
|
||||
if recent_min > older_min.saturating_add(10) {
|
||||
return;
|
||||
}
|
||||
let rise = older_min.min(recent_min).saturating_sub(baseline);
|
||||
if rise > 0 {
|
||||
self.baseline = Some(baseline + (rise / 2).clamp(1, Self::MAX_INCREASE_MS));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current RTT estimate
|
||||
/// Returns None if no valid estimation is available
|
||||
pub fn get_rtt(&self) -> Option<u32> {
|
||||
if let Some(rtt) = self.smoothed_rtt {
|
||||
return Some(rtt);
|
||||
}
|
||||
if self.samples.len() >= Self::MIN_SAMPLES {
|
||||
if let Some(rtt) = self.min_rtt {
|
||||
return Some(rtt);
|
||||
}
|
||||
}
|
||||
None
|
||||
self.baseline
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stable_qos() -> VideoQoS {
|
||||
let mut qos = VideoQoS::default();
|
||||
qos.advance_ms(2000);
|
||||
qos.users.insert(1, UserData::default());
|
||||
for _ in 0..12 {
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
qos
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_delay_spike_does_not_lower_fps() {
|
||||
let mut qos = stable_qos();
|
||||
for delay in [800, 10, 10, 10] {
|
||||
qos.user_network_delay(1, delay);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occasional_spikes_do_not_accumulate_congestion() {
|
||||
let mut qos = stable_qos();
|
||||
for delay in [800, 10, 10].repeat(20) {
|
||||
qos.user_network_delay(1, delay);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sustained_delay_reduces_fps_gradually() {
|
||||
let mut qos = stable_qos();
|
||||
for expected_fps in [30, 30, 24, 24, 24, 20] {
|
||||
qos.user_network_delay(1, 800);
|
||||
assert_eq!(qos.fps(), expected_fps);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_history_keeps_two_samples() {
|
||||
let mut delay = UserDelay::default();
|
||||
for sample in [1, 2, 3] {
|
||||
delay.add_delay(sample);
|
||||
}
|
||||
assert_eq!(delay.delay_history.len(), HISTORY_DELAY_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_timeout_halves_fps_for_each_second_outstanding() {
|
||||
let mut qos = stable_qos();
|
||||
for (elapsed, expected) in [(2001, 15), (3001, 7), (4001, 5), (5001, 5), (6001, 5)] {
|
||||
qos.user_delay_response_elapsed(1, elapsed);
|
||||
assert_eq!(qos.fps(), expected, "{elapsed} ms outstanding");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severe_delay_does_not_wait_for_another_reply() {
|
||||
let mut qos = stable_qos();
|
||||
qos.user_network_delay(1, 1200);
|
||||
assert_eq!(qos.fps(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_timeout_recovers_in_two_good_replies() {
|
||||
let mut qos = stable_qos();
|
||||
qos.user_delay_response_elapsed(1, 3000);
|
||||
assert_eq!(qos.fps(), 7);
|
||||
qos.user_network_delay(1, 3200);
|
||||
assert_eq!(
|
||||
qos.fps(),
|
||||
7,
|
||||
"the late reply belongs to the stall that was braked"
|
||||
);
|
||||
qos.user_delay_response_elapsed(1, 0);
|
||||
qos.user_network_delay(1, 10);
|
||||
assert_eq!(
|
||||
qos.fps(),
|
||||
8,
|
||||
"one good reply must not restore the full frame rate"
|
||||
);
|
||||
qos.user_network_delay(1, 10);
|
||||
assert_eq!(
|
||||
qos.fps(),
|
||||
FPS,
|
||||
"the second good reply restores the frame rate"
|
||||
);
|
||||
qos.user_network_delay(1, 10);
|
||||
assert_eq!(qos.fps(), FPS, "the third keeps the restored frame rate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_aims_lower_after_a_restore_that_congested() {
|
||||
let mut qos = stable_qos();
|
||||
for _ in 0..2 {
|
||||
qos.user_network_delay(1, 1200);
|
||||
}
|
||||
assert_eq!(qos.fps(), 8);
|
||||
qos.user_network_delay(1, 10);
|
||||
qos.user_network_delay(1, 10);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
// The restored level congests at once, so the next restore aims lower.
|
||||
for _ in 0..3 {
|
||||
qos.user_network_delay(1, 400);
|
||||
}
|
||||
assert!(qos.fps() < FPS);
|
||||
qos.user_network_delay(1, 10);
|
||||
qos.user_network_delay(1, 10);
|
||||
assert!(
|
||||
qos.fps() < FPS,
|
||||
"no return to the level that failed: {}",
|
||||
qos.fps()
|
||||
);
|
||||
qos.user_network_delay(1, 10);
|
||||
assert_eq!(qos.fps(), FPS, "ordinary recovery can still reach the cap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_fps_limit_applies_during_delay_spike() {
|
||||
let mut qos = stable_qos();
|
||||
qos.user_custom_fps(1, 12);
|
||||
qos.user_network_delay(1, 800);
|
||||
assert_eq!(qos.fps(), 12);
|
||||
}
|
||||
|
||||
mod adaptation;
|
||||
mod baseline;
|
||||
mod invariants;
|
||||
mod jitter;
|
||||
mod recovery;
|
||||
mod robustness;
|
||||
mod sim;
|
||||
mod smoke;
|
||||
mod startup;
|
||||
}
|
||||
|
||||
353
src/server/video_qos/tests/adaptation.rs
Normal file
353
src/server/video_qos/tests/adaptation.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
//! Regression tests for sustained capacity drops and path-delay/content changes.
|
||||
//! Capacity drops use the closed-loop model; delay and activity fixtures are open-loop.
|
||||
use super::*;
|
||||
|
||||
fn percentile(values: &[f64], p: f64) -> f64 {
|
||||
assert!(!values.is_empty());
|
||||
let mut sorted = values.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
sorted[((sorted.len() - 1) as f64 * p).round() as usize]
|
||||
}
|
||||
|
||||
// Count a fall and subsequent rise of at least `amplitude`, ignoring smaller
|
||||
// reversals. A one-way reduction or recovery is not an oscillation.
|
||||
fn round_trips(values: &[f64], amplitude: f64) -> usize {
|
||||
let mut peak = values[0];
|
||||
let mut trough = peak;
|
||||
let mut falling = false;
|
||||
let mut count = 0;
|
||||
for &value in &values[1..] {
|
||||
if falling {
|
||||
trough = trough.min(value);
|
||||
if value - trough >= amplitude {
|
||||
count += 1;
|
||||
peak = value;
|
||||
falling = false;
|
||||
}
|
||||
} else {
|
||||
peak = peak.max(value);
|
||||
if peak - value >= amplitude {
|
||||
trough = value;
|
||||
falling = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oscillation_metric_distinguishes_recovery_and_small_jitter() {
|
||||
assert_eq!(round_trips(&[30.0, 29.0, 30.0, 28.0, 30.0], 7.5), 0);
|
||||
assert_eq!(round_trips(&[30.0, 20.0, 10.0], 7.5), 0);
|
||||
assert_eq!(round_trips(&[10.0, 20.0, 30.0], 7.5), 0);
|
||||
assert_eq!(round_trips(&[30.0, 10.0, 30.0, 20.0, 30.0], 7.5), 2);
|
||||
assert_eq!(round_trips(&[0.49, 0.17, 0.49], 0.67 * 0.25), 1);
|
||||
}
|
||||
|
||||
struct Oscillation {
|
||||
mean_fps: f64,
|
||||
mean_ratio: f64,
|
||||
fps_span: f64,
|
||||
ratio_span: f64,
|
||||
fps_cycles_per_min: f64,
|
||||
ratio_cycles_per_min: f64,
|
||||
queue_p95_ms: f64,
|
||||
}
|
||||
|
||||
fn permanent_drop(seeds: std::ops::RangeInclusive<u64>) {
|
||||
use super::sim::{self, Summary};
|
||||
|
||||
const STEADY_START_MS: u32 = 120_000;
|
||||
const END_MS: u32 = 600_000;
|
||||
let cases = [
|
||||
("bandwidth_halved_30", 3000.0),
|
||||
("bandwidth_halved_fixed_rate_30", 2000.0),
|
||||
("bandwidth_halved_fixed_rate_no_abr_30", 3000.0),
|
||||
];
|
||||
println!("Permanent drop: 8 -> 2.5 Mbps at 60 s; duration 600 s; seeds {seeds:?}.");
|
||||
println!("Oscillation/queue window: 120-600 s. Spans are p95-p5; round trips require 25% of the requested FPS/ratio in each direction. Delivered FPS/frame age cover 15-600 s.");
|
||||
println!("| scenario | capacity wobble | steady mean FPS (median) | mean ratio (median) | FPS span (p90) | ratio span (p90) | FPS cycles/min (p90) | ratio cycles/min (p90) | queue p95 (p90) | delivered FPS (median) | frame age p95 (p90) |");
|
||||
println!("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|");
|
||||
let mut cases_run = 0;
|
||||
for mut sc in sim::scenarios() {
|
||||
let Some((_, queue_bound_ms)) = cases.iter().find(|(name, _)| *name == sc.name) else {
|
||||
continue;
|
||||
};
|
||||
cases_run += 1;
|
||||
sc.seconds = END_MS / 1000;
|
||||
sc.link.capacity_kbps = vec![(0, 8000.0), (60_000, 2500.0)];
|
||||
let original_wobble = sc.link.wobble;
|
||||
for wobble in [0.0, original_wobble] {
|
||||
sc.link.wobble = wobble;
|
||||
let mut reports = Vec::new();
|
||||
let mut oscillations = Vec::new();
|
||||
for seed in seeds.clone() {
|
||||
sc.seed = seed;
|
||||
let report = sim::run(&sc);
|
||||
assert!(
|
||||
report
|
||||
.trace
|
||||
.iter()
|
||||
.filter(|(t, ..)| (30_000..60_000).contains(t))
|
||||
.all(|(_, fps, _, _)| *fps == sc.limit),
|
||||
"healthy pre-drop phase: {} seed {seed}",
|
||||
sc.name
|
||||
);
|
||||
let steady: Vec<_> = report
|
||||
.trace
|
||||
.iter()
|
||||
.filter(|(t, ..)| *t >= STEADY_START_MS)
|
||||
.collect();
|
||||
let fps: Vec<_> = steady.iter().map(|(_, fps, _, _)| *fps as f64).collect();
|
||||
let ratios: Vec<_> = steady
|
||||
.iter()
|
||||
.map(|(_, _, _, ratio)| *ratio as f64)
|
||||
.collect();
|
||||
let queues: Vec<_> = steady
|
||||
.iter()
|
||||
.map(|(_, _, queue, _)| *queue as f64)
|
||||
.collect();
|
||||
let minutes = (END_MS - STEADY_START_MS) as f64 / 60_000.0;
|
||||
oscillations.push(Oscillation {
|
||||
mean_fps: fps.iter().sum::<f64>() / fps.len() as f64,
|
||||
mean_ratio: ratios.iter().sum::<f64>() / ratios.len() as f64,
|
||||
fps_span: percentile(&fps, 0.95) - percentile(&fps, 0.05),
|
||||
ratio_span: percentile(&ratios, 0.95) - percentile(&ratios, 0.05),
|
||||
fps_cycles_per_min: round_trips(&fps, sc.limit as f64 * 0.25) as f64 / minutes,
|
||||
ratio_cycles_per_min: round_trips(&ratios, sc.quality.ratio() as f64 * 0.25)
|
||||
as f64
|
||||
/ minutes,
|
||||
queue_p95_ms: percentile(&queues, 0.95),
|
||||
});
|
||||
reports.push(report);
|
||||
}
|
||||
let metric = |field: fn(&Oscillation) -> f64, p| {
|
||||
percentile(&oscillations.iter().map(field).collect::<Vec<_>>(), p)
|
||||
};
|
||||
let summary = Summary::of(&reports);
|
||||
let queue_p95 = metric(|o| o.queue_p95_ms, 0.9);
|
||||
println!("| {} | {:.0}% | {:.1} | {:.3} | {:.1} | {:.3} | {:.2} | {:.2} | {:.0} ms | {:.1} | {} ms |",
|
||||
sc.name, wobble * 100.0, metric(|o| o.mean_fps, 0.5),
|
||||
metric(|o| o.mean_ratio, 0.5),
|
||||
metric(|o| o.fps_span, 0.9), metric(|o| o.ratio_span, 0.9),
|
||||
metric(|o| o.fps_cycles_per_min, 0.9), metric(|o| o.ratio_cycles_per_min, 0.9),
|
||||
queue_p95, summary.delivered_median, summary.frame_age_p95_p90);
|
||||
// Queue and frame-age limits reuse the transient-drop budgets;
|
||||
// oscillation statistics are diagnostic.
|
||||
assert!(
|
||||
queue_p95 < *queue_bound_ms,
|
||||
"{}: steady queue {queue_p95} ms",
|
||||
sc.name
|
||||
);
|
||||
assert!(
|
||||
(summary.frame_age_p95_p90 as f64) < *queue_bound_ms,
|
||||
"{summary:?}"
|
||||
);
|
||||
assert!(
|
||||
summary.delivered_median >= sc.limit as f64 * 0.4,
|
||||
"{summary:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(cases_run, cases.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permanent_capacity_drop_600s() {
|
||||
permanent_drop(super::sim::SEEDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "extended permanent-drop coverage over 100 held-out seeds"]
|
||||
fn permanent_capacity_drop_held_out_seeds() {
|
||||
permanent_drop(21..=120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_capacity_preserves_auto_floor_and_recovers() {
|
||||
let mut sc = super::sim::scenarios()
|
||||
.into_iter()
|
||||
.find(|s| s.name == "bandwidth_halved_fixed_rate_no_abr_30")
|
||||
.unwrap();
|
||||
sc.link.capacity_kbps = vec![(0, 8000.0), (60_000, 700.0), (120_000, 8000.0)];
|
||||
for seed in super::sim::SEEDS {
|
||||
sc.seed = seed;
|
||||
let report = super::sim::run(&sc);
|
||||
assert!(
|
||||
report.trace.iter().all(|(_, fps, ..)| *fps >= 5),
|
||||
"seed {seed}: automatic reductions went below 5 FPS"
|
||||
);
|
||||
let congested: Vec<_> = report
|
||||
.trace
|
||||
.iter()
|
||||
.filter(|(t, ..)| (80_000..120_000).contains(t))
|
||||
.collect();
|
||||
let min_fps = congested.iter().map(|(_, fps, ..)| *fps).min().unwrap();
|
||||
let min_queue = congested
|
||||
.iter()
|
||||
.map(|(_, _, queue, _)| *queue)
|
||||
.min()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
min_fps, 5,
|
||||
"seed {seed}: severe congestion must reach the floor"
|
||||
);
|
||||
// At 5 FPS this model sends about 670 kbps, leaving little room to drain
|
||||
// existing backlog at 700 kbps. Require drainage after capacity returns.
|
||||
assert!(
|
||||
report.recovery_ms.is_some_and(|ms| ms <= 20_000),
|
||||
"seed {seed}: recovery took {:?}",
|
||||
report.recovery_ms
|
||||
);
|
||||
println!("700 kbps fixed-rate, seed {seed}: min FPS={min_fps}, min queue={min_queue} ms, queue p95={} ms, frame age p95={} ms, recovery={:?}", report.queue_p95_ms, report.frame_age_p95_ms, report.recovery_ms);
|
||||
}
|
||||
}
|
||||
|
||||
const DISPLAY: &str = "adaptation";
|
||||
|
||||
fn session(abr: bool) -> VideoQoS {
|
||||
let mut qos = super::smoke::session(FPS, Quality::Balanced);
|
||||
qos.abr_config = abr;
|
||||
qos.new_display(DISPLAY.to_owned());
|
||||
qos.set_support_changing_quality(DISPLAY, true);
|
||||
sync_bitrate(&mut qos);
|
||||
qos
|
||||
}
|
||||
|
||||
fn sync_bitrate(qos: &mut VideoQoS) {
|
||||
let bitrate = (6000.0 * qos.ratio()) as u32;
|
||||
qos.store_bitrate(bitrate);
|
||||
}
|
||||
|
||||
fn second(qos: &mut VideoQoS, delay: u32, dynamic: bool) {
|
||||
let encoded = if dynamic { qos.fps() as usize } else { 0 };
|
||||
qos.advance_ms(1000);
|
||||
sync_bitrate(qos);
|
||||
qos.user_network_delay(1, delay);
|
||||
sync_bitrate(qos);
|
||||
qos.update_display_data(DISPLAY, encoded);
|
||||
sync_bitrate(qos);
|
||||
}
|
||||
|
||||
fn baseline_steps() -> Vec<String> {
|
||||
let mut unmet = Vec::new();
|
||||
println!("Baseline step: 90 s at 10 ms, 180 s at new RTT, 90 s at 10 ms; one fresh reply per second. Relearning requires FPS=30 and excess<150 ms throughout the final 60 s at the new RTT.");
|
||||
println!("| ABR | new RTT | cold-start final FPS | learned baseline | final excess | final FPS | final ratio | relearned | returned-path FPS |");
|
||||
println!("|---|---:|---:|---:|---:|---:|---:|---|---:|");
|
||||
for abr in [false, true] {
|
||||
for rtt in [310, 410] {
|
||||
let mut cold = session(abr);
|
||||
for _ in 0..90 {
|
||||
second(&mut cold, rtt, true);
|
||||
assert!(cold.fps() >= INIT_FPS, "stable cold-start RTT {rtt}");
|
||||
}
|
||||
assert_eq!(cold.fps(), FPS);
|
||||
let mut qos = session(abr);
|
||||
for _ in 0..90 {
|
||||
second(&mut qos, 10, true);
|
||||
}
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
let mut relearned = true;
|
||||
for s in 0..180 {
|
||||
second(&mut qos, rtt, true);
|
||||
if s >= 120 {
|
||||
let base = qos.users[&1].delay.rtt_calculator.get_rtt().unwrap();
|
||||
relearned &= qos.fps() == FPS && rtt.saturating_sub(base) < 150;
|
||||
}
|
||||
}
|
||||
let base = qos.users[&1].delay.rtt_calculator.get_rtt().unwrap();
|
||||
let high_fps = qos.fps();
|
||||
let high_ratio = qos.ratio();
|
||||
for _ in 0..90 {
|
||||
second(&mut qos, 10, true);
|
||||
}
|
||||
assert_eq!(
|
||||
qos.fps(),
|
||||
FPS,
|
||||
"return to the original path: ABR={abr} RTT={rtt}"
|
||||
);
|
||||
assert!(qos.ratio() >= BR_BALANCED * 0.95);
|
||||
println!("| {abr} | {rtt} ms | {} | {base} ms | {} ms | {high_fps} | {high_ratio:.3} | {relearned} | {} |",
|
||||
cold.fps(), rtt.saturating_sub(base), qos.fps());
|
||||
if !relearned {
|
||||
unmet.push(format!(
|
||||
"ABR={abr}, RTT 10 -> {rtt} ms: base={base}, FPS={high_fps}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
unmet
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_step_relearns_higher_rtt() {
|
||||
let unmet = baseline_steps();
|
||||
assert!(
|
||||
unmet.is_empty(),
|
||||
"higher baseline was not relearned: {unmet:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_to_dynamic_ratio_recovery() {
|
||||
println!("Static recovery: 90 s healthy video, 12 s confirmed 800 ms delay, 60 s healthy static screen, then 90 s video. Bitrate is modeled as ratio * 6000 kbps.");
|
||||
println!("| restart profile | ratio after cut | ratio after static | time to 90% | time to 95% | final ratio | final FPS | modeled bitrate |");
|
||||
println!("|---|---:|---:|---|---|---:|---:|---:|");
|
||||
for (profile, restart_delay, growing_queue) in [
|
||||
("healthy 10 ms", 10, false),
|
||||
("stable path 800 ms", 800, false),
|
||||
("growing queue 800 + 10 ms/s", 800, true),
|
||||
] {
|
||||
let mut qos = session(true);
|
||||
for _ in 0..90 {
|
||||
second(&mut qos, 10, true);
|
||||
}
|
||||
let target = qos.latest_quality().ratio();
|
||||
assert_eq!(qos.ratio(), target);
|
||||
for _ in 0..12 {
|
||||
second(&mut qos, 800, true);
|
||||
}
|
||||
let after_cut = qos.ratio();
|
||||
assert!(
|
||||
after_cut < target * 0.5,
|
||||
"fixture must confirm congestion and cut bitrate"
|
||||
);
|
||||
for _ in 0..60 {
|
||||
second(&mut qos, 10, false);
|
||||
}
|
||||
let after_static = qos.ratio();
|
||||
let mut t90 = (after_static >= target * 0.90).then_some(0);
|
||||
let mut t95 = (after_static >= target * 0.95).then_some(0);
|
||||
for s in 1..=90 {
|
||||
let delay = restart_delay + if growing_queue { s * 10 } else { 0 };
|
||||
second(&mut qos, delay, true);
|
||||
let ratio = qos.ratio();
|
||||
if ratio >= target * 0.90 {
|
||||
t90.get_or_insert(s);
|
||||
}
|
||||
if ratio >= target * 0.95 {
|
||||
t95.get_or_insert(s);
|
||||
}
|
||||
if growing_queue {
|
||||
assert!(
|
||||
ratio <= after_static * 1.02,
|
||||
"activity must not restore quality into congestion"
|
||||
);
|
||||
}
|
||||
}
|
||||
let seconds = |time: Option<u32>| {
|
||||
time.map(|s| format!("{s} s"))
|
||||
.unwrap_or_else(|| "never".to_owned())
|
||||
};
|
||||
let ratio = qos.ratio();
|
||||
println!("| {profile} | {after_cut:.3} | {after_static:.3} | {} | {} | {ratio:.3} | {} | {} kbps |",
|
||||
seconds(t90), seconds(t95), qos.fps(), qos.bitrate());
|
||||
if !growing_queue {
|
||||
assert!(
|
||||
t95.is_some(),
|
||||
"{profile}: video did not regain 95% quality within 90 s"
|
||||
);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/server/video_qos/tests/baseline.rs
Normal file
110
src/server/video_qos/tests/baseline.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use super::*;
|
||||
|
||||
fn learned_baseline(delay: u32) -> RttCalculator {
|
||||
let mut rtt = RttCalculator::default();
|
||||
for _ in 0..90 {
|
||||
rtt.update(delay);
|
||||
}
|
||||
rtt
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_minimum_expires_after_a_stable_path_change() {
|
||||
for (old, new) in [(10, 310), (10, 500), (159, 500)] {
|
||||
let mut rtt = learned_baseline(old);
|
||||
for i in 0..40 {
|
||||
let before = rtt.get_rtt().unwrap();
|
||||
rtt.update(new + i % 5 * 10);
|
||||
let after = rtt.get_rtt().unwrap();
|
||||
assert!(after <= before + 50, "limit the cost of relearning");
|
||||
if i < 10 {
|
||||
assert_eq!(after, old, "a short burst must not replace the baseline");
|
||||
}
|
||||
}
|
||||
assert_eq!(rtt.get_rtt(), Some(new), "{old} -> {new}");
|
||||
rtt.update(old);
|
||||
assert_eq!(rtt.get_rtt(), Some(old), "a lower delay is direct evidence");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rising_delay_is_not_learned_as_a_new_baseline() {
|
||||
for step in [2, 10, 50] {
|
||||
let mut rtt = learned_baseline(10);
|
||||
for i in 0..120 {
|
||||
rtt.update(200 + i * step);
|
||||
assert_eq!(rtt.get_rtt(), Some(10), "rising by {step} ms per reply");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intermittent_spikes_do_not_raise_the_baseline() {
|
||||
let mut rtt = learned_baseline(159);
|
||||
for delay in [159, 900, 500, 159, 350].repeat(30) {
|
||||
rtt.update(delay);
|
||||
assert_eq!(rtt.get_rtt(), Some(159));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_probes_and_late_replies_do_not_age_the_baseline() {
|
||||
let mut qos = stable_qos();
|
||||
for elapsed in (2001..122_001).step_by(1000) {
|
||||
qos.user_delay_response_elapsed(1, elapsed);
|
||||
assert_eq!(qos.users[&1].delay.rtt_calculator.get_rtt(), Some(10));
|
||||
}
|
||||
qos.user_network_delay(1, 122_000);
|
||||
assert_eq!(qos.users[&1].delay.rtt_calculator.get_rtt(), Some(10));
|
||||
for _ in 0..30 {
|
||||
qos.user_delay_response_elapsed(1, 2500);
|
||||
qos.user_network_delay(1, 2600);
|
||||
}
|
||||
assert_eq!(qos.users[&1].delay.rtt_calculator.get_rtt(), Some(10));
|
||||
assert_eq!(qos.fps(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_path_change_recovers_without_reconnecting() {
|
||||
println!("| ABR | path delay | seconds to 30 FPS | final baseline | final FPS |");
|
||||
println!("|---|---:|---:|---:|---:|");
|
||||
for abr in [false, true] {
|
||||
for delay in [310, 410, 500] {
|
||||
let mut qos = super::smoke::session(FPS, Quality::Balanced);
|
||||
qos.abr_config = abr;
|
||||
qos.new_display("baseline".to_owned());
|
||||
qos.set_support_changing_quality("baseline", true);
|
||||
let second = |qos: &mut VideoQoS, delay| {
|
||||
qos.advance_ms(1000);
|
||||
let bitrate = (6000.0 * qos.ratio()) as u32;
|
||||
qos.store_bitrate(bitrate);
|
||||
qos.user_network_delay(1, delay);
|
||||
let bitrate = (6000.0 * qos.ratio()) as u32;
|
||||
qos.store_bitrate(bitrate);
|
||||
qos.update_display_data("baseline", qos.fps() as usize);
|
||||
};
|
||||
for _ in 0..90 {
|
||||
second(&mut qos, 10);
|
||||
}
|
||||
let mut first_recovered = None;
|
||||
for s in 1..=90 {
|
||||
second(&mut qos, delay);
|
||||
if s >= 10 && qos.fps() == FPS && first_recovered.is_none() {
|
||||
first_recovered = Some(s);
|
||||
}
|
||||
if s >= 40 {
|
||||
assert_eq!(qos.fps(), FPS, "stay recovered: ABR={abr}, delay={delay}");
|
||||
}
|
||||
}
|
||||
let base = qos.users[&1].delay.rtt_calculator.get_rtt().unwrap();
|
||||
println!(
|
||||
"| {abr} | 10 -> {delay} ms | {first_recovered:?} | {base} | {} |",
|
||||
qos.fps()
|
||||
);
|
||||
assert!(first_recovered.is_some_and(|s| s <= 30));
|
||||
assert_eq!(base, delay);
|
||||
second(&mut qos, 10);
|
||||
assert_eq!(qos.users[&1].delay.rtt_calculator.get_rtt(), Some(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
498
src/server/video_qos/tests/invariants.rs
Normal file
498
src/server/video_qos/tests/invariants.rs
Normal file
@@ -0,0 +1,498 @@
|
||||
//! The controller's invariants as properties over random sessions. A scenario
|
||||
//! test pins one trajectory; these hold whatever the trajectory:
|
||||
//!
|
||||
//! 1. viewer isolation: a viewer's private target is a function of its own
|
||||
//! replies, timeouts and limit, never of another viewer's (with ABR on, the
|
||||
//! shared bitrate state is the one designed input: the frame rate keeps a
|
||||
//! floor while the bitrate can still come down);
|
||||
//! 2. bad evidence never raises anything: a bad reply or a timeout tick keeps or
|
||||
//! lowers that viewer's target and the bitrate ratio;
|
||||
//! 3. lifecycle: a join adds a constraint and a leave removes it, and neither
|
||||
//! touches any other viewer's state;
|
||||
//! 4. evidence ownership: a bitrate cut is asked for by a viewer's own evidence,
|
||||
//! by the step that viewer's own evidence calls for, and a newcomer's first
|
||||
//! reply does not spend that evidence again;
|
||||
//! 5. caps: a reply leaves the target within `[MIN_FPS, cap]`, and the stream is
|
||||
//! the aggregation of the targets, the caps and the start-up guards;
|
||||
//! 6. pairing: the late reply of a braked probe does not brake again.
|
||||
use super::*;
|
||||
|
||||
/// xorshift64*, so the tests need no external crate and stay reproducible.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Rng((seed ^ 0x9E37_79B9_7F4A_7C15).max(1))
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.next() % n
|
||||
}
|
||||
|
||||
fn chance(&mut self, pct: u64) -> bool {
|
||||
self.below(100) < pct
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Step {
|
||||
Reply { id: i32, delay: u32 },
|
||||
Timeout { id: i32, elapsed: u128 },
|
||||
Wait(u64),
|
||||
Tick(usize),
|
||||
Cap { id: i32, fps: u32 },
|
||||
}
|
||||
|
||||
const SEEDS: u64 = 150;
|
||||
const STEPS: usize = 300;
|
||||
|
||||
/// Random events for a set of viewers on one link. A probe that is out stays
|
||||
/// out until a reply: the connection reports a growing elapsed time every second
|
||||
/// and the reply that ends the stall carries at least that delay.
|
||||
struct Driver {
|
||||
rng: Rng,
|
||||
ids: Vec<i32>,
|
||||
base_rtt: u32,
|
||||
outstanding: HashMap<i32, u128>,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn new(seed: u64, ids: Vec<i32>) -> Self {
|
||||
let mut rng = Rng::new(seed);
|
||||
let base_rtt = 10 + rng.below(300) as u32;
|
||||
Driver {
|
||||
rng,
|
||||
ids,
|
||||
base_rtt,
|
||||
outstanding: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self) -> Step {
|
||||
let id = self.ids[self.rng.below(self.ids.len() as u64) as usize];
|
||||
let roll = self.rng.below(100);
|
||||
match roll {
|
||||
0..=64 => {
|
||||
let mut delay = if roll < 45 {
|
||||
self.base_rtt + self.rng.below(140) as u32
|
||||
} else {
|
||||
self.base_rtt + DELAY_THRESHOLD_150MS + self.rng.below(1500) as u32
|
||||
};
|
||||
if let Some(elapsed) = self.outstanding.remove(&id) {
|
||||
delay = delay.max(elapsed as u32 + self.rng.below(500) as u32);
|
||||
}
|
||||
Step::Reply { id, delay }
|
||||
}
|
||||
65..=74 => {
|
||||
let elapsed = match self.outstanding.get(&id) {
|
||||
Some(elapsed) => elapsed + 1000,
|
||||
None => 2001 + self.rng.below(1000) as u128,
|
||||
};
|
||||
self.outstanding.insert(id, elapsed);
|
||||
Step::Timeout { id, elapsed }
|
||||
}
|
||||
75..=89 => Step::Wait(self.rng.below(1500)),
|
||||
90..=96 => Step::Tick(self.rng.below(31) as usize),
|
||||
_ => Step::Cap {
|
||||
id,
|
||||
fps: 1 + self.rng.below(60) as u32,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `on_connection_open` inserts, without touching the config store.
|
||||
fn open(qos: &mut VideoQoS, id: i32) {
|
||||
qos.users.insert(
|
||||
id,
|
||||
UserData {
|
||||
joined_at: Some(qos.now()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn session(abr: bool) -> VideoQoS {
|
||||
let mut qos = VideoQoS::default();
|
||||
qos.advance_ms(2000);
|
||||
qos.abr_config = abr;
|
||||
qos.first_reply_adjusts_ratio = true;
|
||||
qos.new_display("test".to_owned());
|
||||
qos.set_support_changing_quality("test", true);
|
||||
qos.store_bitrate(4000);
|
||||
qos
|
||||
}
|
||||
|
||||
/// The video loop reports the encoder's bitrate as soon as it applies a ratio.
|
||||
fn sync_bitrate(qos: &mut VideoQoS) {
|
||||
let target = qos.latest_quality().ratio();
|
||||
let ratio = qos.ratio();
|
||||
qos.store_bitrate((4000.0 * ratio / target) as u32);
|
||||
}
|
||||
|
||||
fn apply(qos: &mut VideoQoS, step: Step) {
|
||||
match step {
|
||||
Step::Reply { id, delay } => qos.user_network_delay(id, delay),
|
||||
Step::Timeout { id, elapsed } => qos.user_delay_response_elapsed(id, elapsed),
|
||||
Step::Wait(ms) => qos.advance_ms(ms),
|
||||
Step::Tick(encoded) => qos.update_display_data("test", encoded),
|
||||
Step::Cap { id, fps } => qos.user_custom_fps(id, fps),
|
||||
}
|
||||
sync_bitrate(qos);
|
||||
}
|
||||
|
||||
/// The viewer's private target, as the controller reads it before a reply.
|
||||
fn target(qos: &VideoQoS, id: i32) -> u32 {
|
||||
let user = &qos.users[&id];
|
||||
user.delay.fps.unwrap_or(INIT_FPS.min(user.fps_cap()))
|
||||
}
|
||||
|
||||
fn baseline(qos: &VideoQoS, id: i32) -> Option<u32> {
|
||||
qos.users[&id].delay.rtt_calculator.get_rtt()
|
||||
}
|
||||
|
||||
/// Everything the controller keeps about a viewer, for change detection.
|
||||
fn snapshot(qos: &VideoQoS, id: i32) -> String {
|
||||
format!("{:?}", qos.users[&id])
|
||||
}
|
||||
|
||||
/// The aggregation `adjust_fps` is meant to compute: the slowest viewer's target,
|
||||
/// INIT_FPS for a viewer without a reply or inside its first second, within the
|
||||
/// lowest cap.
|
||||
fn expected_stream(qos: &VideoQoS) -> u32 {
|
||||
let mut fps = qos
|
||||
.users
|
||||
.values()
|
||||
.map(|u| u.delay.fps.unwrap_or(INIT_FPS))
|
||||
.min()
|
||||
.unwrap_or(INIT_FPS);
|
||||
if qos
|
||||
.users
|
||||
.values()
|
||||
.any(|u| u.joined_at.is_some_and(|j| qos.since(j).as_secs() < 1))
|
||||
{
|
||||
fps = fps.min(INIT_FPS);
|
||||
}
|
||||
let cap = qos
|
||||
.users
|
||||
.values()
|
||||
.map(|u| u.fps_cap())
|
||||
.min()
|
||||
.unwrap_or(FPS);
|
||||
fps.clamp(MIN_FPS, cap)
|
||||
}
|
||||
|
||||
// Invariant 2: bad evidence never raises a target or the ratio.
|
||||
#[test]
|
||||
fn bad_evidence_never_raises_a_target_or_the_ratio() {
|
||||
for seed in 0..SEEDS {
|
||||
for abr in [false, true] {
|
||||
let mut qos = session(abr);
|
||||
let ids: Vec<i32> = (1..=1 + (seed % 3) as i32).collect();
|
||||
for id in &ids {
|
||||
open(&mut qos, *id);
|
||||
}
|
||||
let mut driver = Driver::new(seed, ids);
|
||||
for step_no in 0..STEPS {
|
||||
let step = driver.step();
|
||||
let ratio_before = qos.ratio();
|
||||
let before = match step {
|
||||
Step::Reply { id, .. } | Step::Timeout { id, .. } => Some((id, target(&qos, id))),
|
||||
_ => None,
|
||||
};
|
||||
apply(&mut qos, step);
|
||||
// Bad by the baseline the controller used for this reply: the reply
|
||||
// itself may have relearned it.
|
||||
let bad = match step {
|
||||
Step::Reply { id, delay } => baseline(&qos, id)
|
||||
.is_some_and(|base| delay >= base + DELAY_THRESHOLD_150MS),
|
||||
Step::Timeout { .. } => true,
|
||||
_ => false,
|
||||
};
|
||||
if let (true, Some((id, before))) = (bad, before) {
|
||||
let after = target(&qos, id);
|
||||
assert!(
|
||||
after <= before,
|
||||
"seed {seed} abr {abr} step {step_no} {step:?}: target {before} -> {after}"
|
||||
);
|
||||
assert!(
|
||||
qos.ratio() <= ratio_before,
|
||||
"seed {seed} abr {abr} step {step_no} {step:?}: ratio {ratio_before} -> {}",
|
||||
qos.ratio()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant 2 and 6: a timeout tick keeps or lowers the target, whatever it is.
|
||||
#[test]
|
||||
fn a_timeout_keeps_or_lowers_every_target() {
|
||||
for reference in MIN_FPS..=MAX_FPS {
|
||||
for elapsed in [2001, 2999, 3000, 3001, 4500, 6001, 9000, 30_000] {
|
||||
let mut qos = session(false);
|
||||
open(&mut qos, 1);
|
||||
qos.user_custom_fps(1, MAX_FPS);
|
||||
qos.users.get_mut(&1).unwrap().delay.fps = Some(reference);
|
||||
qos.adjust_fps();
|
||||
let stream = qos.fps();
|
||||
qos.user_delay_response_elapsed(1, elapsed);
|
||||
assert!(
|
||||
target(&qos, 1) <= reference,
|
||||
"{elapsed} ms outstanding at {reference} fps: target {}",
|
||||
target(&qos, 1)
|
||||
);
|
||||
assert!(
|
||||
qos.fps() <= stream,
|
||||
"{elapsed} ms outstanding at {reference} fps: stream {stream} -> {}",
|
||||
qos.fps()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant 6: the late reply of a braked probe does not brake again.
|
||||
#[test]
|
||||
fn a_late_reply_after_a_brake_does_not_brake_again() {
|
||||
for reference in (MIN_FPS + 1..=MAX_FPS).step_by(3) {
|
||||
for elapsed in [2001u32, 3001, 4500, 6001, 9000] {
|
||||
for abr in [false, true] {
|
||||
let mut qos = session(abr);
|
||||
open(&mut qos, 1);
|
||||
qos.user_custom_fps(1, MAX_FPS);
|
||||
for _ in 0..3 {
|
||||
qos.user_network_delay(1, 20);
|
||||
}
|
||||
qos.users.get_mut(&1).unwrap().delay.fps = Some(reference);
|
||||
qos.user_delay_response_elapsed(1, elapsed as u128);
|
||||
let braked = target(&qos, 1);
|
||||
qos.user_network_delay(1, elapsed + 100);
|
||||
assert_eq!(
|
||||
target(&qos, 1),
|
||||
braked,
|
||||
"abr {abr}, {elapsed} ms outstanding at {reference} fps"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Viewer 1's target after each of its own events, alone or with company whose
|
||||
/// events are interleaved: a second viewer with its own replies, timeouts, waits
|
||||
/// and limits, and a third that joins and leaves along the way. The display
|
||||
/// timer is left out: on a dynamic screen it raises the ratio off its floor,
|
||||
/// and the property holds the bitrate state fixed.
|
||||
fn viewer_one_targets(seed: u64, company: bool, abr: bool, at_floor: bool) -> Vec<u32> {
|
||||
let mut own = Driver::new(seed, vec![1]);
|
||||
let mut others = Driver::new(seed ^ 0xC0FF_EE, vec![2]);
|
||||
let mut qos = session(abr);
|
||||
if at_floor {
|
||||
qos.ratio = qos.min_ratio();
|
||||
sync_bitrate(&mut qos);
|
||||
assert!(!qos.can_reduce_bitrate());
|
||||
}
|
||||
open(&mut qos, 1);
|
||||
if company {
|
||||
open(&mut qos, 2);
|
||||
}
|
||||
let no_tick = |step: Step| match step {
|
||||
Step::Tick(_) => Step::Wait(1000),
|
||||
step => step,
|
||||
};
|
||||
let mut targets = Vec::new();
|
||||
for step_no in 0..STEPS {
|
||||
if company {
|
||||
for _ in 0..others.rng.below(3) {
|
||||
let step = no_tick(others.step());
|
||||
apply(&mut qos, step);
|
||||
}
|
||||
if step_no == STEPS / 3 {
|
||||
open(&mut qos, 3);
|
||||
others.ids.push(3);
|
||||
}
|
||||
if step_no == 2 * STEPS / 3 {
|
||||
qos.on_connection_close(3);
|
||||
others.ids.pop();
|
||||
}
|
||||
}
|
||||
let step = no_tick(own.step());
|
||||
apply(&mut qos, step);
|
||||
targets.push(target(&qos, 1));
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
// Invariant 1: another viewer's replies, timeouts, limits, joins and leaves do
|
||||
// not change a viewer's private target. With ABR off the bitrate is fixed; with
|
||||
// ABR on the shared bitrate state is a designed input, so the property is checked
|
||||
// at the bitrate floor, where it can no longer change.
|
||||
#[test]
|
||||
fn a_viewers_target_is_independent_of_other_viewers() {
|
||||
for seed in 0..SEEDS {
|
||||
for (abr, at_floor) in [(false, false), (true, true)] {
|
||||
let alone = viewer_one_targets(seed, false, abr, at_floor);
|
||||
let with_company = viewer_one_targets(seed, true, abr, at_floor);
|
||||
assert_eq!(
|
||||
alone, with_company,
|
||||
"seed {seed} abr {abr}: viewer 1's targets differ with company"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant 3: a join adds a constraint, a leave removes it, and neither touches
|
||||
// another viewer's state.
|
||||
#[test]
|
||||
fn joins_and_leaves_only_change_the_aggregation() {
|
||||
for seed in 0..SEEDS {
|
||||
for abr in [false, true] {
|
||||
let mut qos = session(abr);
|
||||
open(&mut qos, 1);
|
||||
open(&mut qos, 2);
|
||||
let mut driver = Driver::new(seed, vec![1, 2]);
|
||||
let mut next_id = 3;
|
||||
let mut present: Vec<i32> = Vec::new();
|
||||
for step_no in 0..STEPS {
|
||||
let step = driver.step();
|
||||
apply(&mut qos, step);
|
||||
if driver.rng.chance(10) {
|
||||
let others: Vec<i32> = qos.users.keys().copied().collect();
|
||||
let before: Vec<String> = others.iter().map(|id| snapshot(&qos, *id)).collect();
|
||||
qos.adjust_fps();
|
||||
let stream = qos.fps();
|
||||
open(&mut qos, next_id);
|
||||
present.push(next_id);
|
||||
driver.ids.push(next_id);
|
||||
next_id += 1;
|
||||
qos.adjust_fps();
|
||||
assert!(
|
||||
qos.fps() <= stream,
|
||||
"seed {seed} abr {abr} step {step_no}: a join raised the stream {stream} -> {}",
|
||||
qos.fps()
|
||||
);
|
||||
assert_eq!(qos.fps(), expected_stream(&qos));
|
||||
let after: Vec<String> = others.iter().map(|id| snapshot(&qos, *id)).collect();
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"seed {seed} abr {abr} step {step_no}: a join changed a viewer"
|
||||
);
|
||||
} else if !present.is_empty() && driver.rng.chance(10) {
|
||||
let leaving = present.remove(driver.rng.below(present.len() as u64) as usize);
|
||||
driver.ids.retain(|id| *id != leaving);
|
||||
driver.outstanding.remove(&leaving);
|
||||
let others: Vec<i32> = qos.users.keys().copied().filter(|id| *id != leaving).collect();
|
||||
let before: Vec<String> = others.iter().map(|id| snapshot(&qos, *id)).collect();
|
||||
qos.on_connection_close(leaving);
|
||||
let after: Vec<String> = others.iter().map(|id| snapshot(&qos, *id)).collect();
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"seed {seed} abr {abr} step {step_no}: a leave changed a viewer"
|
||||
);
|
||||
assert_eq!(
|
||||
qos.fps(),
|
||||
expected_stream(&qos),
|
||||
"seed {seed} abr {abr} step {step_no}: the stream after a leave"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant 4: the ratio comes down only when a viewer's own evidence asks for
|
||||
// it, by that viewer's own step, and a newcomer's first reply does not spend the
|
||||
// evidence again.
|
||||
#[test]
|
||||
fn a_bitrate_cut_is_owned_by_a_viewers_evidence() {
|
||||
let mut cuts = 0;
|
||||
for seed in 0..SEEDS {
|
||||
let mut qos = session(true);
|
||||
let ids: Vec<i32> = (1..=1 + (seed % 3) as i32).collect();
|
||||
for id in &ids {
|
||||
open(&mut qos, *id);
|
||||
}
|
||||
let mut driver = Driver::new(seed, ids);
|
||||
for step_no in 0..STEPS {
|
||||
let step = driver.step();
|
||||
let before = qos.ratio();
|
||||
apply(&mut qos, step);
|
||||
let after = qos.ratio();
|
||||
if after >= before {
|
||||
continue;
|
||||
}
|
||||
cuts += 1;
|
||||
let asked: Vec<f32> = qos
|
||||
.users
|
||||
.values()
|
||||
.filter_map(|u| u.delay.ratio_reduction())
|
||||
.collect();
|
||||
assert!(
|
||||
!asked.is_empty(),
|
||||
"seed {seed} step {step_no} {step:?}: a cut nobody asked for"
|
||||
);
|
||||
let deepest = asked.iter().copied().fold(1.0_f32, f32::min);
|
||||
assert!(
|
||||
after >= before * deepest * 0.999,
|
||||
"seed {seed} step {step_no} {step:?}: cut {before} -> {after}, deepest step asked {deepest}"
|
||||
);
|
||||
// A newcomer replying inside the cooldown finds the evidence spent.
|
||||
open(&mut qos, 99);
|
||||
qos.advance_ms(driver.rng.below(2900));
|
||||
qos.user_network_delay(99, driver.base_rtt);
|
||||
assert_eq!(
|
||||
qos.ratio(),
|
||||
after,
|
||||
"seed {seed} step {step_no}: a newcomer's first reply spent the evidence again"
|
||||
);
|
||||
qos.on_connection_close(99);
|
||||
}
|
||||
}
|
||||
assert!(cuts > SEEDS as usize, "only {cuts} cuts across {SEEDS} sessions");
|
||||
}
|
||||
|
||||
// Invariant 5: a reply leaves the target within its cap, and the stream is the
|
||||
// aggregation of targets, caps and start-up guards after every decision.
|
||||
#[test]
|
||||
fn targets_stay_within_caps_and_the_stream_is_their_aggregation() {
|
||||
for seed in 0..SEEDS {
|
||||
for abr in [false, true] {
|
||||
let mut qos = session(abr);
|
||||
let ids: Vec<i32> = (1..=1 + (seed % 3) as i32).collect();
|
||||
for id in &ids {
|
||||
open(&mut qos, *id);
|
||||
}
|
||||
let mut driver = Driver::new(seed, ids);
|
||||
for step_no in 0..STEPS {
|
||||
let step = driver.step();
|
||||
apply(&mut qos, step);
|
||||
match step {
|
||||
Step::Reply { id, .. } => {
|
||||
let cap = qos.users[&id].fps_cap();
|
||||
let t = target(&qos, id);
|
||||
assert!(
|
||||
(MIN_FPS..=cap).contains(&t),
|
||||
"seed {seed} abr {abr} step {step_no} {step:?}: target {t} outside [{MIN_FPS}, {cap}]"
|
||||
);
|
||||
}
|
||||
Step::Wait(_) | Step::Cap { .. } => continue,
|
||||
_ => {}
|
||||
}
|
||||
assert_eq!(
|
||||
qos.fps(),
|
||||
expected_stream(&qos),
|
||||
"seed {seed} abr {abr} step {step_no} {step:?}: the stream is not the aggregation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
493
src/server/video_qos/tests/jitter.rs
Normal file
493
src/server/video_qos/tests/jitter.rs
Normal file
@@ -0,0 +1,493 @@
|
||||
use super::*;
|
||||
|
||||
fn abr_session() -> VideoQoS {
|
||||
let mut qos = stable_qos();
|
||||
qos.new_display("test".to_owned());
|
||||
qos.set_support_changing_quality("test", true);
|
||||
qos.store_bitrate(4000);
|
||||
// Linux skips the first-reply adjustment; exercise it on every platform.
|
||||
qos.first_reply_adjusts_ratio = true;
|
||||
qos.advance_ms(4000);
|
||||
qos
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_reduction_precedes_ordinary_fps_reduction() {
|
||||
let mut qos = abr_session();
|
||||
let ratio = qos.ratio();
|
||||
qos.user_network_delay(1, 400);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
assert_eq!(qos.ratio(), ratio);
|
||||
qos.user_network_delay(1, 400);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
assert!(qos.ratio() < ratio);
|
||||
qos.user_network_delay(1, 400);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
qos.user_network_delay(1, 400);
|
||||
assert!(qos.fps() < FPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_cooldown_defers_ordinary_fps_reduction() {
|
||||
let mut qos = abr_session();
|
||||
qos.adjust_ratio_instant = qos.now();
|
||||
let ratio = qos.ratio();
|
||||
for _ in 0..3 {
|
||||
qos.user_network_delay(1, 400);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
assert_eq!(qos.ratio(), ratio);
|
||||
}
|
||||
qos.advance_ms(4000);
|
||||
qos.user_network_delay(1, 400);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
assert!(qos.ratio() < ratio);
|
||||
qos.user_network_delay(1, 400);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
qos.user_network_delay(1, 400);
|
||||
assert!(qos.fps() < FPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_abr_or_minimum_bitrate_does_not_prevent_fps_reduction() {
|
||||
for mode in ["disabled", "unsupported", "minimum"] {
|
||||
let mut qos = abr_session();
|
||||
match mode {
|
||||
"disabled" => qos.abr_config = false,
|
||||
"unsupported" => qos.set_support_changing_quality("test", false),
|
||||
"minimum" => qos.ratio = BR_MIN_HIGH_RESOLUTION,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
for _ in 0..3 {
|
||||
qos.user_network_delay(1, 400);
|
||||
}
|
||||
assert!(qos.fps() < FPS, "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severe_delay_and_timeout_bypass_bitrate_cooldown() {
|
||||
let mut qos = abr_session();
|
||||
qos.adjust_ratio_instant = qos.now();
|
||||
qos.user_network_delay(1, 1200);
|
||||
assert_eq!(qos.fps(), 15);
|
||||
qos.user_delay_response_elapsed(1, 2500);
|
||||
assert_eq!(qos.fps(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_bitrate_during_cooldown_does_not_block_fps_reduction() {
|
||||
// ABR on, ratio at its floor, a good reply cleared the post-reduction counter,
|
||||
// and the adjustment cooldown has just restarted: bitrate cannot help here.
|
||||
let mut qos = abr_session();
|
||||
qos.ratio = BR_MIN_HIGH_RESOLUTION;
|
||||
qos.user_network_delay(1, 10);
|
||||
qos.adjust_ratio_instant = qos.now();
|
||||
for _ in 0..3 {
|
||||
qos.user_network_delay(1, 400);
|
||||
}
|
||||
assert!(qos.fps() < FPS);
|
||||
}
|
||||
|
||||
fn abr_session_from_scratch() -> VideoQoS {
|
||||
let mut qos = VideoQoS::default();
|
||||
qos.advance_ms(2000);
|
||||
qos.users.insert(1, UserData::default());
|
||||
qos.new_display("test".to_owned());
|
||||
qos.set_support_changing_quality("test", true);
|
||||
qos.store_bitrate(4000);
|
||||
qos
|
||||
}
|
||||
|
||||
/// The video loop reports the encoder's bitrate as soon as it applies a new ratio.
|
||||
fn sync_bitrate(qos: &mut VideoQoS) {
|
||||
let target = qos.latest_quality().ratio();
|
||||
let ratio = qos.ratio();
|
||||
qos.store_bitrate((4000.0 * ratio / target) as u32);
|
||||
}
|
||||
|
||||
/// One second of wall clock, one probe reply, one display update: what a
|
||||
/// connection does every second.
|
||||
fn second(qos: &mut VideoQoS, delay: u32, encoded: usize) {
|
||||
qos.advance_ms(1000);
|
||||
sync_bitrate(qos);
|
||||
qos.user_network_delay(1, delay);
|
||||
sync_bitrate(qos);
|
||||
qos.update_display_data("test", encoded);
|
||||
sync_bitrate(qos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_high_rtt_restores_bitrate() {
|
||||
for rtt in [180, 300] {
|
||||
let mut qos = abr_session_from_scratch();
|
||||
let target = qos.latest_quality().ratio();
|
||||
for _ in 0..120 {
|
||||
second(&mut qos, rtt, 30);
|
||||
}
|
||||
assert_eq!(qos.fps(), FPS, "rtt {rtt}");
|
||||
assert!(
|
||||
qos.ratio() >= target * 0.99,
|
||||
"rtt {rtt}: ratio {}",
|
||||
qos.ratio()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn congestion_bitrate_reduction_resets_dynamic_screen_window() {
|
||||
// A static screen encodes about one frame per second. While the congestion path
|
||||
// adjusts the ratio at every cooldown, the periodic branch never runs, so the
|
||||
// encode counter must not keep accumulating across the whole episode.
|
||||
let mut qos = abr_session();
|
||||
for _ in 0..10 {
|
||||
qos.advance_ms(4000);
|
||||
qos.user_network_delay(1, 10);
|
||||
qos.user_network_delay(1, 400);
|
||||
qos.user_network_delay(1, 400);
|
||||
qos.update_display_data("test", 1);
|
||||
}
|
||||
for _ in 0..12 {
|
||||
qos.advance_ms(1000);
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
let ratio = qos.ratio();
|
||||
qos.advance_ms(4000);
|
||||
qos.update_display_data("test", 1);
|
||||
assert!(
|
||||
qos.ratio() <= ratio,
|
||||
"a static screen must not look dynamic after congestion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_stall_does_not_cut_bitrate() {
|
||||
// One probe out for 2.5 s, then its late reply: jitter, not congestion.
|
||||
let mut qos = abr_session();
|
||||
let ratio = qos.ratio();
|
||||
qos.user_delay_response_elapsed(1, 2500);
|
||||
qos.advance_ms(1000);
|
||||
qos.update_display_data("test", 30);
|
||||
assert_eq!(qos.ratio(), ratio, "the timeout tick alone");
|
||||
qos.user_network_delay(1, 2600);
|
||||
assert_eq!(qos.ratio(), ratio, "the late reply alone");
|
||||
qos.user_network_delay(1, 400);
|
||||
assert!(qos.ratio() < ratio, "a second bad reply confirms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stall_beyond_three_seconds_cuts_bitrate() {
|
||||
let mut qos = abr_session();
|
||||
let ratio = qos.ratio();
|
||||
qos.user_delay_response_elapsed(1, 2001);
|
||||
qos.update_display_data("test", 30);
|
||||
assert_eq!(qos.ratio(), ratio);
|
||||
qos.advance_ms(1000);
|
||||
qos.user_delay_response_elapsed(1, 3001);
|
||||
qos.update_display_data("test", 30);
|
||||
assert!(qos.ratio() < ratio, "still out at the next tick");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_high_rtt_does_not_dip_at_start() {
|
||||
for rtt in [180, 300] {
|
||||
let mut qos = super::smoke::session(30, Quality::Balanced);
|
||||
for _ in 0..20 {
|
||||
qos.advance_ms(1000);
|
||||
qos.user_network_delay(1, rtt);
|
||||
assert!(qos.fps() >= INIT_FPS, "rtt {rtt}: {}", qos.fps());
|
||||
}
|
||||
assert_eq!(qos.fps(), FPS, "rtt {rtt}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_severe_congestion_halves_bitrate() {
|
||||
let mut qos = abr_session();
|
||||
let ratio = qos.ratio();
|
||||
qos.user_network_delay(1, 800);
|
||||
qos.user_network_delay(1, 800); // two bad replies: an ordinary step
|
||||
let after_first = qos.ratio();
|
||||
assert!(
|
||||
after_first < ratio && after_first > ratio * 0.75,
|
||||
"{after_first}"
|
||||
);
|
||||
qos.user_network_delay(1, 800); // three: confirmed
|
||||
qos.advance_ms(4000);
|
||||
qos.update_display_data("test", 30);
|
||||
assert!(qos.ratio() <= after_first * 0.55, "{}", qos.ratio());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_holds_its_floor_while_bitrate_can_still_drop() {
|
||||
// With a bitrate-targeted encoder fewer frames do not mean fewer bytes, so the
|
||||
// bitrate comes down first and the frame rate keeps its floor meanwhile.
|
||||
let mut qos = abr_session();
|
||||
let mut reached_floor = false;
|
||||
// Keep the queue growing, rather than presenting a stable new path delay.
|
||||
let mut delay = 400;
|
||||
for _ in 0..60 {
|
||||
qos.advance_ms(3000);
|
||||
second(&mut qos, delay, 30);
|
||||
delay += 10;
|
||||
if qos.ratio() > 0.17 {
|
||||
assert!(
|
||||
qos.fps() >= 10,
|
||||
"fps {} at ratio {}",
|
||||
qos.fps(),
|
||||
qos.ratio()
|
||||
);
|
||||
} else {
|
||||
reached_floor = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
reached_floor,
|
||||
"bitrate must reach its floor: {}",
|
||||
qos.ratio()
|
||||
);
|
||||
for _ in 0..24 {
|
||||
qos.user_network_delay(1, delay);
|
||||
delay += 10;
|
||||
}
|
||||
assert!(
|
||||
qos.fps() < 10,
|
||||
"an exhausted bitrate frees the frame rate: {}",
|
||||
qos.fps()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_timer_does_not_punish_unconfirmed_spikes_or_stale_averages() {
|
||||
let mut qos = abr_session();
|
||||
let ratio = qos.ratio();
|
||||
for delay in [800, 10, 350, 10, 350, 10].repeat(10) {
|
||||
qos.user_network_delay(1, delay);
|
||||
qos.adjust_ratio(false);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
assert_eq!(qos.ratio(), ratio);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewers_confirm_congestion_independently() {
|
||||
let mut qos = stable_qos();
|
||||
qos.users.insert(2, UserData::default());
|
||||
for _ in 0..30 {
|
||||
qos.user_network_delay(2, 10);
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
for id in [1, 2, 1, 2] {
|
||||
qos.user_network_delay(id, 400);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
}
|
||||
qos.user_network_delay(2, 10);
|
||||
qos.user_network_delay(1, 400);
|
||||
assert!(qos.fps() < FPS);
|
||||
qos.user_custom_fps(2, 12);
|
||||
qos.user_network_delay(1, 10);
|
||||
assert_eq!(qos.fps(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_congested_viewer_does_not_lower_another_viewers_target() {
|
||||
let mut qos = stable_qos();
|
||||
qos.users.insert(2, UserData::default());
|
||||
for _ in 0..30 {
|
||||
qos.user_network_delay(2, 10);
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
// Viewer 1 congests; the stream follows the slowest viewer.
|
||||
for _ in 0..2 {
|
||||
qos.user_network_delay(1, 1200);
|
||||
}
|
||||
assert_eq!(qos.fps(), 8);
|
||||
// Viewer 2 is fine and keeps its own target rather than inheriting viewer 1's.
|
||||
qos.user_network_delay(2, 10);
|
||||
assert_eq!(qos.users[&2].delay.fps, Some(FPS));
|
||||
// Once viewer 1 restores, the stream is back at once.
|
||||
for _ in 0..3 {
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_probe_checks_do_not_count_as_fresh_bad_replies() {
|
||||
let mut qos = stable_qos();
|
||||
qos.user_network_delay(1, 400);
|
||||
for elapsed in [1000, 1500, 1900] {
|
||||
qos.user_delay_response_elapsed(1, elapsed);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
}
|
||||
qos.user_network_delay(1, 400);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
qos.user_network_delay(1, 10);
|
||||
qos.user_network_delay(1, 400);
|
||||
qos.user_network_delay(1, 400);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_continues_with_intermittent_jitter() {
|
||||
let mut qos = stable_qos();
|
||||
for _ in 0..3 {
|
||||
qos.user_network_delay(1, 1200);
|
||||
}
|
||||
assert_eq!(qos.fps(), 5);
|
||||
for delay in [10, 350].repeat(30) {
|
||||
qos.user_network_delay(1, delay);
|
||||
}
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_limit_of_one_viewer_does_not_lower_another_viewers_target() {
|
||||
let mut qos = stable_qos();
|
||||
qos.users.insert(2, UserData::default());
|
||||
for _ in 0..30 {
|
||||
qos.user_network_delay(2, 10);
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
qos.user_custom_fps(2, 12);
|
||||
qos.user_network_delay(1, 10);
|
||||
assert_eq!(qos.fps(), 12, "the stream follows the lowest limit");
|
||||
assert_eq!(
|
||||
qos.users[&1].delay.fps,
|
||||
Some(FPS),
|
||||
"viewer 1's own target is not a function of viewer 2's limit"
|
||||
);
|
||||
qos.on_connection_close(2);
|
||||
assert_eq!(
|
||||
qos.fps(),
|
||||
FPS,
|
||||
"the stream is back the moment the limit is gone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_viewers_first_reply_does_not_bypass_bitrate_cooldown() {
|
||||
let mut qos = abr_session();
|
||||
for _ in 0..3 {
|
||||
qos.user_network_delay(1, 800);
|
||||
}
|
||||
// Viewer 1 is confirmed and its evidence was spent on a cut a moment ago.
|
||||
let ratio = qos.ratio();
|
||||
assert!(ratio < Quality::Balanced.ratio());
|
||||
qos.users.insert(2, UserData::default());
|
||||
qos.user_network_delay(2, 10);
|
||||
assert_eq!(
|
||||
qos.ratio(),
|
||||
ratio,
|
||||
"viewer 2's first reply must not spend viewer 1's evidence again inside the cooldown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_viewer_does_not_inherit_another_viewers_congested_fps() {
|
||||
let mut qos = stable_qos();
|
||||
for _ in 0..2 {
|
||||
qos.user_network_delay(1, 1200);
|
||||
}
|
||||
assert_eq!(qos.fps(), 8);
|
||||
qos.users.insert(2, UserData::default());
|
||||
qos.user_network_delay(2, 10);
|
||||
assert!(
|
||||
qos.users[&2].delay.fps >= Some(INIT_FPS),
|
||||
"a new viewer starts from INIT_FPS, not from the congested stream: {:?}",
|
||||
qos.users[&2].delay.fps
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconfirmed_severe_viewer_does_not_amplify_another_viewers_confirmed_mild_congestion() {
|
||||
let mut qos = abr_session();
|
||||
qos.users.insert(2, UserData::default());
|
||||
for _ in 0..30 {
|
||||
qos.user_network_delay(2, 10);
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
// The first reply of a new viewer adjusts the ratio and restarts the cooldown.
|
||||
qos.advance_ms(4000);
|
||||
let ratio = qos.ratio();
|
||||
// Viewer 2: mild congestion, confirmed over three replies. Viewer 1: one
|
||||
// severe spike, never confirmed. Each viewer on its own calls for at most a
|
||||
// five percent step; together they must not turn into a halving.
|
||||
qos.user_network_delay(2, 200);
|
||||
qos.user_network_delay(1, 1200);
|
||||
qos.user_network_delay(2, 200);
|
||||
let after_two = qos.ratio();
|
||||
assert!(after_two < ratio, "viewer 2's second bad reply cuts");
|
||||
assert!(
|
||||
after_two >= ratio * 0.94,
|
||||
"viewer 2's own mild excess is a five percent step, not {after_two}"
|
||||
);
|
||||
qos.user_network_delay(2, 200);
|
||||
qos.advance_ms(4000);
|
||||
qos.update_display_data("test", 30);
|
||||
assert!(
|
||||
qos.ratio() >= after_two * 0.94,
|
||||
"viewer 1's severity must not be paired with viewer 2's confirmation: {}",
|
||||
qos.ratio()
|
||||
);
|
||||
}
|
||||
|
||||
/// What `on_connection_open` inserts, without touching the config store.
|
||||
fn newcomer(qos: &VideoQoS) -> UserData {
|
||||
UserData {
|
||||
joined_at: Some(qos.now()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_just_opened_viewer_does_not_throttle_existing_viewers() {
|
||||
let mut qos = stable_qos();
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
qos.users.insert(2, newcomer(&qos));
|
||||
assert_eq!(
|
||||
qos.fps(),
|
||||
FPS,
|
||||
"nothing changes until the stream is re-aggregated"
|
||||
);
|
||||
qos.on_connection_close(2);
|
||||
assert_eq!(
|
||||
qos.fps(),
|
||||
FPS,
|
||||
"the guard leaves with the viewer that brought it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_viewer_caps_the_stream_at_init_fps_for_a_second() {
|
||||
let mut qos = stable_qos();
|
||||
qos.users.insert(2, newcomer(&qos));
|
||||
qos.user_network_delay(1, 10);
|
||||
assert_eq!(qos.fps(), INIT_FPS);
|
||||
qos.advance_ms(1000);
|
||||
qos.user_network_delay(2, 10);
|
||||
qos.user_network_delay(1, 10);
|
||||
assert!(
|
||||
qos.fps() > INIT_FPS,
|
||||
"after a second the stream follows the viewers' own targets: {}",
|
||||
qos.fps()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_latest_newcomer_keeps_an_earlier_newcomers_guard() {
|
||||
let mut qos = stable_qos();
|
||||
qos.users.insert(2, newcomer(&qos));
|
||||
qos.user_network_delay(2, 10);
|
||||
assert_eq!(qos.fps(), INIT_FPS);
|
||||
qos.advance_ms(100);
|
||||
qos.users.insert(3, newcomer(&qos));
|
||||
qos.advance_ms(100);
|
||||
qos.on_connection_close(3);
|
||||
assert_eq!(
|
||||
qos.fps(),
|
||||
INIT_FPS,
|
||||
"viewer 2 is still inside its own start-up window"
|
||||
);
|
||||
}
|
||||
120
src/server/video_qos/tests/recovery.rs
Normal file
120
src/server/video_qos/tests/recovery.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ordinary_congestion_waits_between_bounded_cuts() {
|
||||
for delay in [400, 800] {
|
||||
let mut qos = stable_qos();
|
||||
for _ in 0..2 {
|
||||
qos.user_network_delay(1, delay);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
}
|
||||
qos.user_network_delay(1, delay);
|
||||
let first_cut = qos.fps();
|
||||
assert!((24..FPS).contains(&first_cut), "delay={delay}: {first_cut}");
|
||||
for _ in 0..2 {
|
||||
qos.user_network_delay(1, delay);
|
||||
assert_eq!(qos.fps(), first_cut, "wait for new evidence after a cut");
|
||||
}
|
||||
qos.user_network_delay(1, delay);
|
||||
assert!(qos.fps() < first_cut);
|
||||
assert!(qos.fps() >= first_cut - first_cut / 5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_floor_preserves_lower_custom_limits() {
|
||||
for limit in [1, 3, 5, 30, 60, 120] {
|
||||
for abr in [false, true] {
|
||||
for timeout in [false, true] {
|
||||
let mut qos = super::smoke::session(limit, Quality::Balanced);
|
||||
qos.abr_config = abr;
|
||||
qos.new_display("test".to_owned());
|
||||
qos.set_support_changing_quality("test", true);
|
||||
for _ in 0..90 {
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
assert_eq!(qos.fps(), limit);
|
||||
qos.ratio = BR_MIN_HIGH_RESOLUTION;
|
||||
qos.store_bitrate(600);
|
||||
let floor = 5.min(limit);
|
||||
if timeout {
|
||||
for elapsed in [2001, 3001, 4001, 5001, 10_000, 30_000] {
|
||||
qos.user_delay_response_elapsed(1, elapsed);
|
||||
assert!(
|
||||
(floor..=limit).contains(&qos.fps()),
|
||||
"timeout={elapsed}, limit={limit}, ABR={abr}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for _ in 0..8 {
|
||||
qos.user_network_delay(1, 1500);
|
||||
assert!(
|
||||
(floor..=limit).contains(&qos.fps()),
|
||||
"limit={limit}, ABR={abr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
qos.fps(),
|
||||
floor,
|
||||
"timeout={timeout}, limit={limit}, ABR={abr}"
|
||||
);
|
||||
if timeout {
|
||||
qos.user_network_delay(1, 30_100);
|
||||
assert_eq!(qos.fps(), floor, "a late reply must preserve the floor");
|
||||
}
|
||||
for _ in 0..2 {
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
assert_eq!(qos.fps(), limit, "recover: timeout={timeout}, ABR={abr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_good_replies_restore_after_a_severe_stall() {
|
||||
let mut qos = stable_qos();
|
||||
qos.user_delay_response_elapsed(1, 5001);
|
||||
assert_eq!(qos.fps(), 5);
|
||||
qos.user_network_delay(1, 5100);
|
||||
assert_eq!(qos.fps(), 5, "the late reply must not brake twice");
|
||||
qos.user_network_delay(1, 10);
|
||||
assert!(
|
||||
(5..FPS).contains(&qos.fps()),
|
||||
"one good reply is not enough"
|
||||
);
|
||||
qos.user_network_delay(1, 10);
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_during_recovery_does_not_discard_the_restore_target() {
|
||||
let mut qos = stable_qos();
|
||||
for _ in 0..3 {
|
||||
qos.user_network_delay(1, 1200);
|
||||
}
|
||||
for delay in [10, 350, 10] {
|
||||
qos.user_network_delay(1, delay);
|
||||
}
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_fast_restore_rolls_back_before_the_queue_grows() {
|
||||
let mut qos = stable_qos();
|
||||
qos.user_delay_response_elapsed(1, 5001);
|
||||
for _ in 0..2 {
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
assert_eq!(qos.fps(), FPS);
|
||||
qos.user_network_delay(1, 800);
|
||||
assert_eq!(qos.fps(), FPS / 2);
|
||||
for _ in 0..2 {
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
assert!(
|
||||
qos.fps() < FPS,
|
||||
"a failed restore must lower the next probe"
|
||||
);
|
||||
}
|
||||
180
src/server/video_qos/tests/robustness.rs
Normal file
180
src/server/video_qos/tests/robustness.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
//! Guards against tuning the controller to the simulator: the CI bounds applied
|
||||
//! to seeds that never took part in setting them, and a sweep of the scenario
|
||||
//! parameters. Both are `#[ignore]`d: they take a few seconds and are meant for
|
||||
//! anyone changing a controller constant or a bound.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --lib video_qos::tests::robustness -- --ignored --nocapture
|
||||
//! ```
|
||||
use super::sim::{bound_violations, run, scenarios, Report, Scenario, Summary};
|
||||
use super::*;
|
||||
|
||||
fn summarise(sc: &Scenario, seeds: impl Iterator<Item = u64>) -> Summary {
|
||||
let reports: Vec<Report> = seeds
|
||||
.map(|seed| run(&Scenario { seed, ..sc.clone() }))
|
||||
.collect();
|
||||
Summary::of(&reports)
|
||||
}
|
||||
|
||||
/// Seeds 21 to 120 in five blocks of twenty, each block held to the CI bounds.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn held_out_seeds() {
|
||||
println!("| scenario | blocks violating | which | median target | worst p10 | below limit/2 (p90) | queue p95 (p90) |");
|
||||
println!("|---|---:|---|---:|---:|---:|---:|");
|
||||
let mut failures = Vec::new();
|
||||
for sc in scenarios() {
|
||||
let mut failing_blocks = 0;
|
||||
let mut which: Vec<&str> = Vec::new();
|
||||
let mut reports: Vec<Report> = Vec::new();
|
||||
for block in 0..5u64 {
|
||||
let first = 21 + block * 20;
|
||||
let block_reports: Vec<Report> = (first..first + 20)
|
||||
.map(|seed| run(&Scenario { seed, ..sc.clone() }))
|
||||
.collect();
|
||||
let s = Summary::of(&block_reports);
|
||||
reports.extend(block_reports);
|
||||
let violations = bound_violations(&s);
|
||||
if !violations.is_empty() {
|
||||
failing_blocks += 1;
|
||||
for v in violations {
|
||||
if !which.contains(&v) {
|
||||
which.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let all = Summary::of(&reports);
|
||||
println!(
|
||||
"| {} | {}/5 | {} | {:.1} | {} | {:.1}% | {} ms |",
|
||||
sc.name,
|
||||
failing_blocks,
|
||||
which.join("; "),
|
||||
all.mean_target_median,
|
||||
all.p10_target_worst,
|
||||
all.below_half_p90,
|
||||
all.queue_p95_p90
|
||||
);
|
||||
if failing_blocks > 0 {
|
||||
failures.push(format!(
|
||||
"{} ({} of 5 blocks: {})",
|
||||
sc.name,
|
||||
failing_blocks,
|
||||
which.join("; ")
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"bounds fail on held-out seeds, so they were fitted to the CI seeds: {failures:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// One scenario parameter at a time, halved and doubled, twenty seeds each. No
|
||||
/// bounds: the point is to see that nothing falls off a cliff, and to compare with
|
||||
/// master by running the same test there.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn sensitivity() {
|
||||
let base = scenarios();
|
||||
let pick = |name: &str| base.iter().find(|s| s.name == name).unwrap().clone();
|
||||
let home = pick("home_wifi_30");
|
||||
let halved = pick("bandwidth_halved_30");
|
||||
let with_link = |sc: &Scenario, edit: &dyn Fn(&mut super::sim::Link)| {
|
||||
let mut link = sc.link.clone();
|
||||
edit(&mut link);
|
||||
Scenario { link, ..sc.clone() }
|
||||
};
|
||||
let variants: Vec<(&str, Scenario)> = vec![
|
||||
("home base", home.clone()),
|
||||
(
|
||||
"home stalls half as long",
|
||||
with_link(&home, &|l| l.stall_ms = (150.0, 1250.0)),
|
||||
),
|
||||
(
|
||||
"home stalls twice as long",
|
||||
with_link(&home, &|l| l.stall_ms = (600.0, 5000.0)),
|
||||
),
|
||||
(
|
||||
"home stalls twice as often",
|
||||
with_link(&home, &|l| l.stall_mean_interval_s = 10.0),
|
||||
),
|
||||
(
|
||||
"home stalls half as often",
|
||||
with_link(&home, &|l| l.stall_mean_interval_s = 40.0),
|
||||
),
|
||||
(
|
||||
"home loss doubled",
|
||||
with_link(&home, &|l| l.loss_per_s = 0.4),
|
||||
),
|
||||
(
|
||||
"home capacity halved",
|
||||
with_link(&home, &|l| l.capacity_kbps = vec![(0, 10_000.0)]),
|
||||
),
|
||||
(
|
||||
"home capacity 6 Mbps",
|
||||
with_link(&home, &|l| l.capacity_kbps = vec![(0, 6_000.0)]),
|
||||
),
|
||||
(
|
||||
"home jitter heavier",
|
||||
with_link(&home, &|l| {
|
||||
l.jitter_sigma = 1.5;
|
||||
l.jitter_median_ms = 30.0;
|
||||
}),
|
||||
),
|
||||
(
|
||||
"home base rtt 80 ms",
|
||||
with_link(&home, &|l| l.base_rtt_ms = 80.0),
|
||||
),
|
||||
(
|
||||
"home 60 fps limit",
|
||||
Scenario {
|
||||
limit: 60,
|
||||
..home.clone()
|
||||
},
|
||||
),
|
||||
("bandwidth halved base", halved.clone()),
|
||||
(
|
||||
"bandwidth to 4 Mbps",
|
||||
with_link(&halved, &|l| {
|
||||
l.capacity_kbps = vec![(0, 8_000.0), (60_000, 4_000.0), (120_000, 8_000.0)]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"bandwidth to 1.5 Mbps",
|
||||
with_link(&halved, &|l| {
|
||||
l.capacity_kbps = vec![(0, 8_000.0), (60_000, 1_500.0), (120_000, 8_000.0)]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"bandwidth halved, never restored, 300 s",
|
||||
Scenario {
|
||||
seconds: 300,
|
||||
..with_link(&halved, &|l| {
|
||||
l.capacity_kbps = vec![(0, 8_000.0), (60_000, 2_500.0)]
|
||||
})
|
||||
},
|
||||
),
|
||||
];
|
||||
println!("| variant | median target | worst p10 | below limit/2 (p90) | queue p95 (p90) | delivered | sustained recovery (worst) |");
|
||||
println!("|---|---:|---:|---:|---:|---:|---:|");
|
||||
for (label, sc) in &variants {
|
||||
let s = summarise(sc, super::sim::SEEDS);
|
||||
println!(
|
||||
"| {} | {:.1} | {} | {:.1}% | {} ms | {:.1} | {} |",
|
||||
label,
|
||||
s.mean_target_median,
|
||||
s.p10_target_worst,
|
||||
s.below_half_p90,
|
||||
s.queue_p95_p90,
|
||||
s.delivered_median,
|
||||
if s.has_restore {
|
||||
s.recovery_worst_ms
|
||||
.map(|ms| format!("{:.1}s", ms as f64 / 1000.0))
|
||||
.unwrap_or_else(|| "never".to_owned())
|
||||
} else {
|
||||
"-".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
988
src/server/video_qos/tests/sim.rs
Normal file
988
src/server/video_qos/tests/sim.rs
Normal file
@@ -0,0 +1,988 @@
|
||||
//! Closed-loop network simulation for the QoS controller.
|
||||
//!
|
||||
//! The controller is driven the way `Connection` drives it: one TestDelay probe per
|
||||
//! second, a single probe outstanding, `user_delay_response_elapsed` on every timer
|
||||
//! tick, `update_display_data` once per second. Video frames and probes share one
|
||||
//! FIFO, which stands for the downstream shared path (stream, transport, link): the
|
||||
//! probe measures the bytes that were handed to that path in front of it. It is not
|
||||
//! the server's `tx_video` channel, which the probe does not pass through, and the
|
||||
//! model does not stall the timer while a send is blocked, as the real loop does.
|
||||
//!
|
||||
//! Three independent random streams keep an A/B comparison paired: the network
|
||||
//! trace (capacity wobble, stalls, loss events) is generated before the run from the
|
||||
//! network stream alone, probe jitter is a per-second table from its own stream,
|
||||
//! and scene changes follow the wall clock, so two controllers with the same seed
|
||||
//! face the same link, the same jitter and the same content timeline whatever they
|
||||
//! decide. Only the frame size noise depends on how many frames were produced.
|
||||
//!
|
||||
//! The encoder model conserves its bitrate budget: a scene change costs three
|
||||
//! frames' worth of data and the surplus is repaid by the following frames, so the
|
||||
//! long-term offered load does not depend on the frame rate under CBR.
|
||||
//!
|
||||
//! It still is a model, not a network: it does not reproduce a real transport's
|
||||
//! congestion control or a real encoder. Its job is to show how the controller
|
||||
//! reacts to the *kind* of behaviour a home Wi-Fi, a stable relay or a saturated
|
||||
//! uplink produce, deterministically and over many seeds.
|
||||
//!
|
||||
//! Against overfitting: the CI run uses seeds 1 to 20; `robustness.rs` applies the
|
||||
//! same bounds to seeds 21 to 120 and sweeps the scenario parameters. Scenario
|
||||
//! parameters are educated guesses until a recorded `qos_trace` calibrates them.
|
||||
use super::*;
|
||||
|
||||
/// xorshift64* generator, so the tests need no external crate and stay reproducible.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Rng((seed ^ 0x9E37_79B9_7F4A_7C15).max(1))
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
|
||||
/// Uniform in `[0, 1)`.
|
||||
fn uniform(&mut self) -> f64 {
|
||||
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
fn range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + (hi - lo) * self.uniform()
|
||||
}
|
||||
|
||||
fn normal(&mut self) -> f64 {
|
||||
let u1 = (1.0 - self.uniform()).max(1e-12);
|
||||
let u2 = self.uniform();
|
||||
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
|
||||
}
|
||||
|
||||
fn log_normal(&mut self, median: f64, sigma: f64) -> f64 {
|
||||
median * (sigma * self.normal()).exp()
|
||||
}
|
||||
|
||||
fn exponential(&mut self, mean: f64) -> f64 {
|
||||
-mean * (1.0 - self.uniform()).max(1e-12).ln()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Link {
|
||||
/// Step schedule `(from_ms, kbps)`, sorted by time.
|
||||
pub capacity_kbps: Vec<(u32, f64)>,
|
||||
/// Slow random walk of the capacity, as a fraction of the nominal value.
|
||||
pub wobble: f64,
|
||||
pub base_rtt_ms: f64,
|
||||
/// Log-normal jitter added to every probe round trip.
|
||||
pub jitter_median_ms: f64,
|
||||
pub jitter_sigma: f64,
|
||||
/// Loss events per second. A reliable stream turns a loss into a 200-400 ms
|
||||
/// retransmission stall followed by a second at half rate.
|
||||
pub loss_per_s: f64,
|
||||
/// Mean interval between link stalls in seconds, `0` for none.
|
||||
pub stall_mean_interval_s: f64,
|
||||
/// Uniform stall duration range in milliseconds.
|
||||
pub stall_ms: (f64, f64),
|
||||
}
|
||||
|
||||
impl Link {
|
||||
fn capacity_at(&self, now_ms: u32) -> f64 {
|
||||
self.capacity_kbps
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(from, _)| *from <= now_ms)
|
||||
.map(|(_, kbps)| *kbps)
|
||||
.unwrap_or(self.capacity_kbps[0].1)
|
||||
}
|
||||
|
||||
/// Time at which the capacity was last restored to its initial value, if it ever dropped.
|
||||
fn restore_ms(&self) -> Option<u32> {
|
||||
let initial = self.capacity_kbps[0].1;
|
||||
let mut dropped = false;
|
||||
for (from, kbps) in &self.capacity_kbps {
|
||||
if *kbps < initial {
|
||||
dropped = true;
|
||||
} else if dropped && *kbps >= initial {
|
||||
return Some(*from);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the link does during a run, decided before the run starts.
|
||||
struct LinkTrace {
|
||||
capacity_kbps: Vec<f64>, // per tick, wobble and retransmission backoff applied
|
||||
stalled: Vec<bool>, // per tick
|
||||
}
|
||||
|
||||
fn mark(flags: &mut [bool], from_ms: f64, to_ms: f64) {
|
||||
let from = (from_ms / TICK_MS as f64).max(0.0) as usize;
|
||||
let to = ((to_ms / TICK_MS as f64).ceil() as usize).min(flags.len());
|
||||
for flag in flags.iter_mut().take(to).skip(from) {
|
||||
*flag = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn link_trace(link: &Link, ticks: usize, rng: &mut Rng) -> LinkTrace {
|
||||
let mut capacity_kbps = vec![0.0; ticks];
|
||||
let mut stalled = vec![false; ticks];
|
||||
let mut backoff = vec![false; ticks];
|
||||
let mut wobble = 0.0_f64;
|
||||
for (i, capacity) in capacity_kbps.iter_mut().enumerate() {
|
||||
let now = i as u32 * TICK_MS;
|
||||
if now % 100 == 0 {
|
||||
wobble = (wobble + rng.normal() * 0.03).clamp(-link.wobble, link.wobble);
|
||||
}
|
||||
*capacity = link.capacity_at(now) * (1.0 + wobble);
|
||||
}
|
||||
if link.stall_mean_interval_s > 0.0 {
|
||||
let mut start = rng.exponential(link.stall_mean_interval_s) * 1000.0;
|
||||
while start < (ticks as f64) * TICK_MS as f64 {
|
||||
let len = rng.range(link.stall_ms.0, link.stall_ms.1);
|
||||
mark(&mut stalled, start, start + len);
|
||||
start += rng.exponential(link.stall_mean_interval_s) * 1000.0;
|
||||
}
|
||||
}
|
||||
if link.loss_per_s > 0.0 {
|
||||
let per_tick = link.loss_per_s * TICK_MS as f64 / 1000.0;
|
||||
for i in 0..ticks {
|
||||
if rng.uniform() < per_tick {
|
||||
let start = (i as u32 * TICK_MS) as f64;
|
||||
let len = rng.range(200.0, 400.0);
|
||||
mark(&mut stalled, start, start + len);
|
||||
mark(&mut backoff, start + len, start + len + 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (capacity, backoff) in capacity_kbps.iter_mut().zip(&backoff) {
|
||||
if *backoff {
|
||||
*capacity *= 0.5;
|
||||
}
|
||||
}
|
||||
LinkTrace {
|
||||
capacity_kbps,
|
||||
stalled,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Content {
|
||||
/// Every frame changes: a video call or a movie.
|
||||
Video,
|
||||
/// Mostly static: a couple of changed frames per second.
|
||||
Office,
|
||||
}
|
||||
|
||||
/// How the encoder turns a bitrate into frame sizes.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum EncoderModel {
|
||||
/// VP8, VP9 and AV1 run CBR against millisecond timestamps: fewer frames per
|
||||
/// second means bigger frames, the bitrate stays. Only the ratio moves bytes.
|
||||
Cbr,
|
||||
/// Hardware encoders configured for a fixed 30 fps rate-control assumption:
|
||||
/// every frame carries a thirtieth of the bitrate, so fewer frames mean fewer
|
||||
/// bytes. Actual hardware behaviour is backend dependent (Android's MediaCodec
|
||||
/// path runs VBR).
|
||||
FixedRate,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Scenario {
|
||||
pub name: &'static str,
|
||||
pub seconds: u32,
|
||||
pub limit: u32,
|
||||
pub quality: Quality,
|
||||
pub abr: bool,
|
||||
pub content: Content,
|
||||
pub encoder: EncoderModel,
|
||||
pub link: Link,
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
/// Bitrate at ratio 1.0; balanced quality (0.67) then encodes at about 4 Mbps.
|
||||
const BASE_KBPS: f64 = 6000.0;
|
||||
/// The frame rate hardware encoders are configured for.
|
||||
const ENCODER_CONFIGURED_FPS: f64 = 30.0;
|
||||
/// Log-normal spread of frame sizes around their budget.
|
||||
const FRAME_SIZE_SIGMA: f64 = 0.35;
|
||||
const TICK_MS: u32 = 10;
|
||||
/// Samples taken before this instant belong to the cold start, not the steady state.
|
||||
const WARM_UP_MS: u32 = 15_000;
|
||||
/// A recovery counts once target and queue have held for this long.
|
||||
const SUSTAINED_MS: u32 = 5_000;
|
||||
/// Seeds every scenario is run with.
|
||||
pub const SEEDS: std::ops::RangeInclusive<u64> = 1..=20;
|
||||
|
||||
/// Frame sizes with a conserved bitrate budget.
|
||||
struct Encoder {
|
||||
model: EncoderModel,
|
||||
content: Content,
|
||||
rng: Rng,
|
||||
next_scene_ms: u32,
|
||||
debt_bits: f64,
|
||||
}
|
||||
|
||||
/// A scene change every five seconds of video.
|
||||
const SCENE_INTERVAL_MS: u32 = 5_000;
|
||||
|
||||
impl Encoder {
|
||||
fn frame_bits(&mut self, now_ms: u32, bitrate_kbps: f64, produce_rate: f64) -> f64 {
|
||||
let target = match (self.content, self.model) {
|
||||
// A changed region of a static screen is small whatever the rate control does.
|
||||
(Content::Office, _) => bitrate_kbps * 1000.0 / ENCODER_CONFIGURED_FPS * 0.3,
|
||||
(Content::Video, EncoderModel::Cbr) => bitrate_kbps * 1000.0 / produce_rate,
|
||||
(Content::Video, EncoderModel::FixedRate) => {
|
||||
bitrate_kbps * 1000.0 / ENCODER_CONFIGURED_FPS
|
||||
}
|
||||
};
|
||||
// Mean one: the spread must not change the offered load.
|
||||
let noise = self.rng.log_normal(1.0, FRAME_SIZE_SIGMA)
|
||||
* (-FRAME_SIZE_SIGMA * FRAME_SIZE_SIGMA / 2.0).exp();
|
||||
let mut bits = target * noise;
|
||||
// Scene changes follow the wall clock, not the frame count, so every
|
||||
// controller meets the same content timeline.
|
||||
let scene_change = self.content == Content::Video && now_ms >= self.next_scene_ms;
|
||||
if scene_change {
|
||||
self.next_scene_ms += SCENE_INTERVAL_MS;
|
||||
// A scene change costs a few frames' worth of data; rate control claws
|
||||
// it back from the frames that follow.
|
||||
bits *= 3.0;
|
||||
self.debt_bits += bits - target;
|
||||
} else if self.debt_bits > 0.0 {
|
||||
let repay = self.debt_bits.min(target * 0.5).min(bits * 0.5);
|
||||
bits -= repay;
|
||||
self.debt_bits -= repay;
|
||||
}
|
||||
bits
|
||||
}
|
||||
}
|
||||
|
||||
struct Packet {
|
||||
bits: f64,
|
||||
enqueued_ms: u32,
|
||||
probe_sent_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Report {
|
||||
pub name: String,
|
||||
pub seed: u64,
|
||||
pub limit: u32,
|
||||
/// Controller target, sampled every 100 ms after the warm-up.
|
||||
pub mean_target_fps: f64,
|
||||
pub p10_target_fps: u32,
|
||||
pub min_target_fps: u32,
|
||||
/// Share of the measured time the target spent below half of the limit.
|
||||
pub below_half_pct: f64,
|
||||
/// Frames the encoder produced per second.
|
||||
pub produced_fps: f64,
|
||||
/// Frames that left the shared path per second.
|
||||
pub delivered_fps: f64,
|
||||
/// Time a delivered frame spent in the shared path, 95th percentile.
|
||||
pub frame_age_p95_ms: u32,
|
||||
pub queue_p95_ms: u32,
|
||||
pub max_delay_ms: u32,
|
||||
/// Whether the link drops and restores its capacity at all.
|
||||
pub has_restore: bool,
|
||||
/// Time from the capacity restore until target at the limit and queue below
|
||||
/// 200 ms held for `SUSTAINED_MS`.
|
||||
pub recovery_ms: Option<u32>,
|
||||
/// Lowest target during the first `WARM_UP_MS`.
|
||||
pub cold_start_min_fps: u32,
|
||||
/// First time the target reached 90% of the limit.
|
||||
pub time_to_90pct_ms: Option<u32>,
|
||||
pub final_fps: u32,
|
||||
pub final_ratio: f32,
|
||||
pub trace: Vec<(u32, u32, u32, f32)>, // (time_ms, target fps, queue_ms, ratio)
|
||||
}
|
||||
|
||||
fn percentile_u32(values: &[u32], p: f64) -> u32 {
|
||||
if values.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let mut sorted = values.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted[(((sorted.len() - 1) as f64) * p).round() as usize]
|
||||
}
|
||||
|
||||
fn percentile_f64(values: &[f64], p: f64) -> f64 {
|
||||
if values.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sorted = values.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
sorted[(((sorted.len() - 1) as f64) * p).round() as usize]
|
||||
}
|
||||
|
||||
pub fn run(sc: &Scenario) -> Report {
|
||||
let mut network_rng = Rng::new(sc.seed);
|
||||
let mut probe_rng = Rng::new(sc.seed ^ 0x5052_4F42_45);
|
||||
let mut encoder = Encoder {
|
||||
model: sc.encoder,
|
||||
content: sc.content,
|
||||
rng: Rng::new(sc.seed ^ 0x454E_434F_4445),
|
||||
next_scene_ms: SCENE_INTERVAL_MS,
|
||||
debt_bits: 0.0,
|
||||
};
|
||||
let total_ms = sc.seconds * 1000;
|
||||
let ticks = (total_ms / TICK_MS) as usize;
|
||||
let link = link_trace(&sc.link, ticks, &mut network_rng);
|
||||
// Probe jitter indexed by the probe's send second, so the number of probes a
|
||||
// controller manages to send does not change the jitter the next one meets.
|
||||
let probe_jitter_ms: Vec<f64> = (0..=sc.seconds)
|
||||
.map(|_| probe_rng.log_normal(sc.link.jitter_median_ms, sc.link.jitter_sigma))
|
||||
.collect();
|
||||
|
||||
let mut qos = super::smoke::session(sc.limit, sc.quality);
|
||||
qos.abr_config = sc.abr;
|
||||
if sc.abr {
|
||||
qos.new_display("sim".to_owned());
|
||||
qos.set_support_changing_quality("sim", true);
|
||||
}
|
||||
|
||||
let mut queue: VecDeque<Packet> = VecDeque::new();
|
||||
let mut queued_bits = 0.0_f64;
|
||||
let mut encode_phase = 0.0_f64;
|
||||
let mut encoded_this_second = 0_usize;
|
||||
let mut probe_sent: Option<u32> = None;
|
||||
let mut replies: Vec<(u32, u32)> = Vec::new(); // (arrive_ms, delay_ms)
|
||||
let restore_ms = sc.link.restore_ms();
|
||||
|
||||
let mut fps_samples = Vec::new();
|
||||
let mut queue_samples = Vec::new();
|
||||
let mut produced = 0_u64;
|
||||
let mut delivered = 0_u64;
|
||||
let mut frame_ages = Vec::new();
|
||||
let mut trace = Vec::new();
|
||||
let mut max_delay = 0_u32;
|
||||
let mut recovery_ms = None;
|
||||
let mut good_since: Option<u32> = None;
|
||||
let mut cold_start_min_fps = u32::MAX;
|
||||
let mut time_to_90pct_ms = None;
|
||||
|
||||
for tick in 0..ticks {
|
||||
let now = tick as u32 * TICK_MS;
|
||||
qos.advance_ms(TICK_MS as u64);
|
||||
let capacity_kbps = link.capacity_kbps[tick];
|
||||
|
||||
// Encoder: frames at the controller's rate, sized by the controller's ratio.
|
||||
// The video loop reports the bitrate as soon as it applies a new ratio.
|
||||
let fps = qos.fps();
|
||||
let ratio = qos.ratio();
|
||||
let bitrate_kbps = BASE_KBPS * ratio as f64;
|
||||
qos.store_bitrate(bitrate_kbps as u32);
|
||||
let produce_rate = match sc.content {
|
||||
Content::Video => fps as f64,
|
||||
Content::Office => (fps as f64).min(2.0),
|
||||
};
|
||||
encode_phase += produce_rate * TICK_MS as f64 / 1000.0;
|
||||
while encode_phase >= 1.0 {
|
||||
encode_phase -= 1.0;
|
||||
encoded_this_second += 1;
|
||||
if now >= WARM_UP_MS {
|
||||
produced += 1;
|
||||
}
|
||||
let bits = encoder.frame_bits(now, bitrate_kbps, produce_rate);
|
||||
queue.push_back(Packet {
|
||||
bits,
|
||||
enqueued_ms: now,
|
||||
probe_sent_ms: None,
|
||||
});
|
||||
queued_bits += bits;
|
||||
}
|
||||
|
||||
// Shared path drain: probes are tiny and leave as soon as they reach the head.
|
||||
if !link.stalled[tick] {
|
||||
let mut budget = capacity_kbps * TICK_MS as f64;
|
||||
while budget > 0.0 {
|
||||
let Some(head) = queue.front_mut() else { break };
|
||||
if let Some(sent) = head.probe_sent_ms {
|
||||
let round_trip = sc.link.base_rtt_ms + probe_jitter_ms[(sent / 1000) as usize];
|
||||
let arrive = now + round_trip as u32;
|
||||
replies.push((arrive, arrive - sent));
|
||||
queue.pop_front();
|
||||
continue;
|
||||
}
|
||||
let take = budget.min(head.bits);
|
||||
head.bits -= take;
|
||||
queued_bits -= take;
|
||||
budget -= take;
|
||||
if head.bits <= 1e-9 {
|
||||
if now >= WARM_UP_MS {
|
||||
delivered += 1;
|
||||
frame_ages.push(now - head.enqueued_ms);
|
||||
}
|
||||
queue.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Probe replies reach the controller.
|
||||
replies.sort_by_key(|r| r.0);
|
||||
while replies.first().is_some_and(|r| r.0 <= now) {
|
||||
let (_, delay) = replies.remove(0);
|
||||
max_delay = max_delay.max(delay);
|
||||
probe_sent = None;
|
||||
qos.user_network_delay(1, delay);
|
||||
}
|
||||
|
||||
// The connection's one second timer.
|
||||
if now % 1000 == 0 {
|
||||
if probe_sent.is_none() {
|
||||
probe_sent = Some(now);
|
||||
queue.push_back(Packet {
|
||||
bits: 0.0,
|
||||
enqueued_ms: now,
|
||||
probe_sent_ms: Some(now),
|
||||
});
|
||||
}
|
||||
qos.user_delay_response_elapsed(1, (now - probe_sent.unwrap()) as u128);
|
||||
if sc.abr {
|
||||
qos.update_display_data("sim", encoded_this_second);
|
||||
}
|
||||
encoded_this_second = 0;
|
||||
}
|
||||
|
||||
if now % 100 == 0 {
|
||||
let queue_ms = (queued_bits / capacity_kbps.max(1.0)) as u32;
|
||||
let fps = qos.fps();
|
||||
trace.push((now, fps, queue_ms, qos.ratio()));
|
||||
if now < WARM_UP_MS {
|
||||
cold_start_min_fps = cold_start_min_fps.min(fps);
|
||||
} else {
|
||||
fps_samples.push(fps);
|
||||
queue_samples.push(queue_ms);
|
||||
}
|
||||
if time_to_90pct_ms.is_none() && fps * 10 >= sc.limit * 9 {
|
||||
time_to_90pct_ms = Some(now);
|
||||
}
|
||||
if let Some(restore) = restore_ms {
|
||||
if now >= restore && recovery_ms.is_none() {
|
||||
if fps >= sc.limit && queue_ms < 200 {
|
||||
let since = *good_since.get_or_insert(now);
|
||||
if now - since >= SUSTAINED_MS {
|
||||
recovery_ms = Some(since - restore);
|
||||
}
|
||||
} else {
|
||||
good_since = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let measured_s = (total_ms - WARM_UP_MS) as f64 / 1000.0;
|
||||
let below_half = fps_samples.iter().filter(|f| **f * 2 < sc.limit).count();
|
||||
Report {
|
||||
name: sc.name.to_owned(),
|
||||
seed: sc.seed,
|
||||
limit: sc.limit,
|
||||
mean_target_fps: fps_samples.iter().map(|f| *f as f64).sum::<f64>()
|
||||
/ fps_samples.len().max(1) as f64,
|
||||
p10_target_fps: percentile_u32(&fps_samples, 0.10),
|
||||
min_target_fps: fps_samples.iter().copied().min().unwrap_or(0),
|
||||
below_half_pct: 100.0 * below_half as f64 / fps_samples.len().max(1) as f64,
|
||||
produced_fps: produced as f64 / measured_s,
|
||||
delivered_fps: delivered as f64 / measured_s,
|
||||
frame_age_p95_ms: percentile_u32(&frame_ages, 0.95),
|
||||
queue_p95_ms: percentile_u32(&queue_samples, 0.95),
|
||||
max_delay_ms: max_delay,
|
||||
has_restore: restore_ms.is_some(),
|
||||
recovery_ms,
|
||||
cold_start_min_fps,
|
||||
time_to_90pct_ms,
|
||||
final_fps: qos.fps(),
|
||||
final_ratio: qos.ratio(),
|
||||
trace,
|
||||
}
|
||||
}
|
||||
|
||||
/// One scenario over all seeds, summarised by the statistics the assertions use.
|
||||
#[derive(Debug)]
|
||||
pub struct Summary {
|
||||
pub name: String,
|
||||
pub limit: u32,
|
||||
pub mean_target_median: f64,
|
||||
pub p10_target_worst: u32,
|
||||
pub below_half_p90: f64,
|
||||
pub queue_p95_p90: u32,
|
||||
pub delivered_median: f64,
|
||||
pub frame_age_p95_p90: u32,
|
||||
pub has_restore: bool,
|
||||
/// Slowest sustained recovery, `None` when any seed never recovered.
|
||||
pub recovery_worst_ms: Option<u32>,
|
||||
pub cold_start_min_median: u32,
|
||||
/// Slowest time to 90% of the limit, `None` when any seed never got there.
|
||||
pub time_to_90pct_worst_ms: Option<u32>,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
pub fn of(reports: &[Report]) -> Self {
|
||||
let f = |g: fn(&Report) -> f64| reports.iter().map(g).collect::<Vec<_>>();
|
||||
let u = |g: fn(&Report) -> u32| reports.iter().map(g).collect::<Vec<_>>();
|
||||
let all = |g: fn(&Report) -> Option<u32>| {
|
||||
reports
|
||||
.iter()
|
||||
.map(g)
|
||||
.try_fold(0, |worst, ms| ms.map(|ms| worst.max(ms)))
|
||||
};
|
||||
Summary {
|
||||
name: reports[0].name.clone(),
|
||||
limit: reports[0].limit,
|
||||
mean_target_median: percentile_f64(&f(|r| r.mean_target_fps), 0.5),
|
||||
p10_target_worst: percentile_u32(&u(|r| r.p10_target_fps), 0.0),
|
||||
below_half_p90: percentile_f64(&f(|r| r.below_half_pct), 0.9),
|
||||
queue_p95_p90: percentile_u32(&u(|r| r.queue_p95_ms), 0.9),
|
||||
delivered_median: percentile_f64(&f(|r| r.delivered_fps), 0.5),
|
||||
frame_age_p95_p90: percentile_u32(&u(|r| r.frame_age_p95_ms), 0.9),
|
||||
has_restore: reports[0].has_restore,
|
||||
recovery_worst_ms: all(|r| r.recovery_ms),
|
||||
cold_start_min_median: percentile_u32(&u(|r| r.cold_start_min_fps), 0.5),
|
||||
time_to_90pct_worst_ms: all(|r| r.time_to_90pct_ms),
|
||||
}
|
||||
}
|
||||
|
||||
pub const HEADER: &'static str = "| scenario | limit | target fps (median of means) | worst p10 | below limit/2 (p90) | queue p95 (p90) | delivered fps (median) | frame age p95 (p90) | sustained recovery (worst) | cold-start min (median) | time to 90% (worst) |\n|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|";
|
||||
|
||||
pub fn row(&self) -> String {
|
||||
let secs = |ms: Option<u32>| {
|
||||
ms.map(|ms| format!("{:.1}s", ms as f64 / 1000.0))
|
||||
.unwrap_or_else(|| "never".to_owned())
|
||||
};
|
||||
format!(
|
||||
"| {} | {} | {:.1} | {} | {:.1}% | {} ms | {:.1} | {} ms | {} | {} | {} |",
|
||||
self.name,
|
||||
self.limit,
|
||||
self.mean_target_median,
|
||||
self.p10_target_worst,
|
||||
self.below_half_p90,
|
||||
self.queue_p95_p90,
|
||||
self.delivered_median,
|
||||
self.frame_age_p95_p90,
|
||||
if self.has_restore {
|
||||
secs(self.recovery_worst_ms)
|
||||
} else {
|
||||
"-".to_owned()
|
||||
},
|
||||
self.cold_start_min_median,
|
||||
secs(self.time_to_90pct_worst_ms),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_link(capacity_kbps: f64) -> Link {
|
||||
Link {
|
||||
capacity_kbps: vec![(0, capacity_kbps)],
|
||||
wobble: 0.05,
|
||||
base_rtt_ms: 15.0,
|
||||
jitter_median_ms: 3.0,
|
||||
jitter_sigma: 0.5,
|
||||
loss_per_s: 0.0,
|
||||
stall_mean_interval_s: 0.0,
|
||||
stall_ms: (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Weak-signal home Wi-Fi with ample average capacity: heavy-tailed jitter,
|
||||
/// retransmissions, and a link stall of up to 2.5 s every twenty seconds or so.
|
||||
/// Deliberately nasty; it isolates "capacity is fine, timing is not".
|
||||
fn home_wifi_link() -> Link {
|
||||
Link {
|
||||
capacity_kbps: vec![(0, 20_000.0)],
|
||||
wobble: 0.5,
|
||||
base_rtt_ms: 8.0,
|
||||
jitter_median_ms: 15.0,
|
||||
jitter_sigma: 1.0,
|
||||
loss_per_s: 0.2,
|
||||
stall_mean_interval_s: 20.0,
|
||||
stall_ms: (300.0, 2500.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn intercontinental_link() -> Link {
|
||||
Link {
|
||||
capacity_kbps: vec![(0, 20_000.0)],
|
||||
wobble: 0.1,
|
||||
base_rtt_ms: 250.0,
|
||||
jitter_median_ms: 5.0,
|
||||
jitter_sigma: 0.5,
|
||||
loss_per_s: 0.05,
|
||||
stall_mean_interval_s: 0.0,
|
||||
stall_ms: (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 8 Mbps for a minute, 2.5 Mbps for the next, 8 Mbps again.
|
||||
fn halved_link() -> Link {
|
||||
Link {
|
||||
capacity_kbps: vec![(0, 8_000.0), (60_000, 2_500.0), (120_000, 8_000.0)],
|
||||
..clean_link(8_000.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_link() -> Link {
|
||||
Link {
|
||||
capacity_kbps: vec![(0, 6_000.0)],
|
||||
wobble: 0.4,
|
||||
base_rtt_ms: 40.0,
|
||||
jitter_median_ms: 30.0,
|
||||
jitter_sigma: 0.8,
|
||||
loss_per_s: 0.02,
|
||||
stall_mean_interval_s: 0.0,
|
||||
stall_ms: (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scenarios() -> Vec<Scenario> {
|
||||
let base = |name, limit, link, abr, encoder| Scenario {
|
||||
name,
|
||||
seconds: 180,
|
||||
limit,
|
||||
quality: Quality::Balanced,
|
||||
abr,
|
||||
content: Content::Video,
|
||||
encoder,
|
||||
link,
|
||||
seed: 1,
|
||||
};
|
||||
use EncoderModel::*;
|
||||
vec![
|
||||
base("home_wifi_30", 30, home_wifi_link(), true, Cbr),
|
||||
base("home_wifi_60", 60, home_wifi_link(), true, Cbr),
|
||||
base(
|
||||
"home_wifi_fixed_rate_30",
|
||||
30,
|
||||
home_wifi_link(),
|
||||
true,
|
||||
FixedRate,
|
||||
),
|
||||
base("home_wifi_no_abr_30", 30, home_wifi_link(), false, Cbr),
|
||||
Scenario {
|
||||
content: Content::Office,
|
||||
..base("office_home_wifi_30", 30, home_wifi_link(), true, Cbr)
|
||||
},
|
||||
base("city_relay_30", 30, clean_link(50_000.0), true, Cbr),
|
||||
base("city_relay_60", 60, clean_link(50_000.0), true, Cbr),
|
||||
base(
|
||||
"intercontinental_30",
|
||||
30,
|
||||
intercontinental_link(),
|
||||
true,
|
||||
Cbr,
|
||||
),
|
||||
base("bandwidth_halved_30", 30, halved_link(), true, Cbr),
|
||||
base(
|
||||
"bandwidth_halved_fixed_rate_30",
|
||||
30,
|
||||
halved_link(),
|
||||
true,
|
||||
FixedRate,
|
||||
),
|
||||
base(
|
||||
"bandwidth_halved_fixed_rate_no_abr_30",
|
||||
30,
|
||||
halved_link(),
|
||||
false,
|
||||
FixedRate,
|
||||
),
|
||||
base("bandwidth_halved_no_abr_30", 30, halved_link(), false, Cbr),
|
||||
base("mobile_bufferbloat_30", 30, mobile_link(), true, Cbr),
|
||||
]
|
||||
}
|
||||
|
||||
/// Runs every scenario over `SEEDS` and returns the per-scenario summaries.
|
||||
pub fn run_all() -> Vec<(Summary, Vec<Report>)> {
|
||||
scenarios()
|
||||
.iter()
|
||||
.map(|sc| {
|
||||
let reports: Vec<Report> = SEEDS
|
||||
.map(|seed| run(&Scenario { seed, ..sc.clone() }))
|
||||
.collect();
|
||||
(Summary::of(&reports), reports)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_traces(results: &[(Summary, Vec<Report>)]) {
|
||||
use std::fmt::Write;
|
||||
if let Ok(path) = std::env::var("RUSTDESK_QOS_SIM_CSV") {
|
||||
let mut csv = String::from("scenario,seed,time_ms,target_fps,queue_ms,ratio\n");
|
||||
for (_, reports) in results {
|
||||
for report in reports {
|
||||
for (t, fps, queue, ratio) in &report.trace {
|
||||
writeln!(
|
||||
csv,
|
||||
"{},{},{t},{fps},{queue},{ratio:.3}",
|
||||
report.name, report.seed
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::write(path, csv).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sim_scenarios() {
|
||||
let results = run_all();
|
||||
println!("{}", Summary::HEADER);
|
||||
for (summary, _) in &results {
|
||||
println!("{}", summary.row());
|
||||
}
|
||||
if std::env::var("RUSTDESK_QOS_SIM_VERBOSE").is_ok() {
|
||||
for (_, reports) in &results {
|
||||
for r in reports {
|
||||
println!(
|
||||
"{} seed {}: target mean {:.1} p10 {} min {} below-half {:.1}% delivered {:.1} age p95 {} queue p95 {} max probe {} recovery {:?} cold-start min {} t90 {:?}",
|
||||
r.name, r.seed, r.mean_target_fps, r.p10_target_fps, r.min_target_fps,
|
||||
r.below_half_pct, r.delivered_fps, r.frame_age_p95_ms, r.queue_p95_ms,
|
||||
r.max_delay_ms, r.recovery_ms, r.cold_start_min_fps, r.time_to_90pct_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
write_traces(&results);
|
||||
for (summary, _) in &results {
|
||||
let violations = bound_violations(summary);
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"{}: {violations:?}\n{summary:?}",
|
||||
summary.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds every scenario summary has to meet, shared by the CI run over `SEEDS`
|
||||
/// and by the held-out run in `robustness.rs`. They state what the product needs,
|
||||
/// not what one seed produced. If a new seed or a new scenario violates a bound,
|
||||
/// change the design or loosen the bound with a written reason; never tune a
|
||||
/// controller constant until the bound passes.
|
||||
pub fn bound_violations(s: &Summary) -> Vec<&'static str> {
|
||||
let name = s.name.as_str();
|
||||
let limit = s.limit as f64;
|
||||
let mut v = Vec::new();
|
||||
let mut check = |ok: bool, what: &'static str| {
|
||||
if !ok {
|
||||
v.push(what);
|
||||
}
|
||||
};
|
||||
if name.starts_with("home_wifi") || name.starts_with("office_home_wifi") {
|
||||
// A jittery but healthy link must stay fast: the whole point of the change.
|
||||
// The target rarely leaves the limit, never collapses, and stalls of up to
|
||||
// 2.5 s leave about a second of queue at worst.
|
||||
check(
|
||||
s.mean_target_median >= 0.85 * limit,
|
||||
"median target below 85%",
|
||||
);
|
||||
check(s.p10_target_worst * 3 >= s.limit, "worst p10 below a third");
|
||||
check(s.below_half_p90 <= 10.0, "below half the limit over 10%");
|
||||
check(s.queue_p95_p90 < 1000, "queue p95 p90 over 1 s");
|
||||
if name != "office_home_wifi_30" {
|
||||
check(s.delivered_median >= 0.8 * limit, "delivered below 80%");
|
||||
}
|
||||
// Frame age is the time a delivered frame spent in the shared path: what a
|
||||
// viewer waits for on top of the round trip. A jittery high-capacity link
|
||||
// contains isolated stalls of up to 2.5 s, and the bound is on the p90 of
|
||||
// per-seed p95 frame age: isolated stalls are tolerated, but they must not
|
||||
// turn into a sustained multi-second backlog. A regression bound, not a
|
||||
// latency target; set from the scenario, not from a run.
|
||||
check(s.frame_age_p95_p90 < 1500, "frame age p95 p90 over 1.5 s");
|
||||
} else if name.starts_with("city_relay") {
|
||||
// A clean link is where the developers test; every seed sits at the limit,
|
||||
// and a fresh connection reaches 90% of it within ten seconds.
|
||||
check(s.p10_target_worst == s.limit, "left the limit");
|
||||
check(s.queue_p95_p90 < 50, "queue on a clean link");
|
||||
check(s.frame_age_p95_p90 < 100, "frame age on a clean link");
|
||||
check(
|
||||
s.time_to_90pct_worst_ms.is_some_and(|ms| ms <= 10_000),
|
||||
"cold start over 10 s",
|
||||
);
|
||||
} else if name == "intercontinental_30" {
|
||||
// High but stable RTT is not congestion, not even during the cold start.
|
||||
check(
|
||||
s.mean_target_median >= 0.9 * limit,
|
||||
"median target below 90%",
|
||||
);
|
||||
check(
|
||||
s.cold_start_min_median >= INIT_FPS,
|
||||
"cold start below INIT_FPS",
|
||||
);
|
||||
// Frame age excludes the round trip, so a high RTT earns no allowance.
|
||||
check(s.frame_age_p95_p90 < 150, "frame age over 150 ms");
|
||||
check(
|
||||
s.time_to_90pct_worst_ms.is_some_and(|ms| ms <= 10_000),
|
||||
"cold start over 10 s",
|
||||
);
|
||||
} else if let Some((queue_p95_bound_ms, below_half_bound_pct)) = match name {
|
||||
// Real congestion must be detected, drained and recovered from. With a CBR
|
||||
// encoder only the bitrate drains the queue, and three probe replies at one
|
||||
// second cadence plus a three second ratio cooldown are needed before a
|
||||
// confirmed cut, so a few seconds of queue are inherent there. Without
|
||||
// ABR nothing drains a CBR queue at all, so that combination is reported
|
||||
// but not asserted.
|
||||
"bandwidth_halved_30" => Some((3000, 40.0)),
|
||||
"bandwidth_halved_fixed_rate_30" => Some((2000, 10.0)),
|
||||
"bandwidth_halved_fixed_rate_no_abr_30" => Some((3000, 30.0)),
|
||||
_ => None,
|
||||
} {
|
||||
check(s.queue_p95_p90 < queue_p95_bound_ms, "queue p95 p90 bound");
|
||||
check(
|
||||
s.frame_age_p95_p90 < queue_p95_bound_ms,
|
||||
"frame age p95 p90 bound",
|
||||
);
|
||||
check(s.below_half_p90 <= below_half_bound_pct, "below half bound");
|
||||
check(
|
||||
s.recovery_worst_ms.is_some_and(|ms| ms <= 20_000),
|
||||
"sustained recovery over 20 s",
|
||||
);
|
||||
} else if name == "mobile_bufferbloat_30" {
|
||||
check(s.queue_p95_p90 < 2000, "queue p95 p90 over 2 s");
|
||||
check(s.frame_age_p95_p90 < 2000, "frame age p95 p90 over 2 s");
|
||||
check(s.below_half_p90 <= 15.0, "below half the limit over 15%");
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Replays `qos_trace` lines through a fresh controller and returns
|
||||
/// `(time_ms, id, recorded_fps, replayed_fps)` per line. Open loop, FPS only:
|
||||
/// the recorded delays do not react to the replayed decisions, the session runs
|
||||
/// with ABR off, and connections are replayed as separate viewers of one 30 fps
|
||||
/// balanced session. Time advances by the wall-clock delta between consecutive
|
||||
/// lines whatever their connection, or by one second per line when a trace
|
||||
/// predates the `t=` field.
|
||||
pub fn replay(text: &str) -> Vec<(u64, i32, u64, u32)> {
|
||||
// A present but malformed value is a corrupt trace, not a missing field.
|
||||
let field = |line: &str, key: &str| -> Option<u64> {
|
||||
line.split_whitespace()
|
||||
.find_map(|kv| kv.strip_prefix(key).and_then(|v| v.strip_prefix('=')))
|
||||
.map(|v| {
|
||||
v.parse()
|
||||
.unwrap_or_else(|e| panic!("bad {key}={v:?} in {line:?}: {e}"))
|
||||
})
|
||||
};
|
||||
let mut qos = super::smoke::session(30, Quality::Balanced);
|
||||
qos.users.clear();
|
||||
let mut last_t: Option<u64> = None;
|
||||
let mut now = 0_u64;
|
||||
let mut trace = Vec::new();
|
||||
for line in text.lines().filter(|l| l.contains("qos_trace")) {
|
||||
let id = field(line, "id").unwrap_or(1) as i32;
|
||||
qos.users.entry(id).or_default();
|
||||
let t = field(line, "t");
|
||||
let step = match (t, last_t) {
|
||||
(Some(t), Some(prev)) => t.saturating_sub(prev).clamp(1, 10_000),
|
||||
_ => 1000,
|
||||
};
|
||||
if t.is_some() {
|
||||
last_t = t;
|
||||
}
|
||||
now += step;
|
||||
qos.advance_ms(step);
|
||||
if let Some(elapsed) = field(line, "timeout") {
|
||||
qos.user_delay_response_elapsed(id, elapsed as u128);
|
||||
} else if let Some(delay) = field(line, "delay") {
|
||||
qos.user_delay_response_elapsed(id, 0);
|
||||
qos.user_network_delay(id, delay as u32);
|
||||
}
|
||||
let recorded = field(line, "fps").unwrap_or(0);
|
||||
trace.push((now, id, recorded, qos.fps()));
|
||||
}
|
||||
trace
|
||||
}
|
||||
|
||||
/// Replays the log named by `RUSTDESK_QOS_TRACE` and prints the result.
|
||||
#[test]
|
||||
fn replay_recorded_trace() {
|
||||
let Ok(path) = std::env::var("RUSTDESK_QOS_TRACE") else {
|
||||
return;
|
||||
};
|
||||
let trace = replay(&std::fs::read_to_string(&path).unwrap());
|
||||
println!("time_ms,id,recorded_fps,replayed_fps");
|
||||
for (t, id, recorded, replayed) in &trace {
|
||||
println!("{t},{id},{recorded},{replayed}");
|
||||
}
|
||||
let mean = trace.iter().map(|t| t.3 as f64).sum::<f64>() / trace.len().max(1) as f64;
|
||||
println!(
|
||||
"replayed mean target fps: {mean:.1} over {} lines",
|
||||
trace.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_time_axis_is_shared_across_connections() {
|
||||
// Two viewers each log once a second for twenty seconds: twenty seconds of
|
||||
// wall clock, not forty.
|
||||
let text: String = (0..20)
|
||||
.flat_map(|i| {
|
||||
[
|
||||
format!("qos_trace t={} id=1 delay=10 fps=30\n", 100_000 + i * 1000),
|
||||
format!("qos_trace t={} id=2 delay=10 fps=30\n", 100_001 + i * 1000),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let trace = replay(&text);
|
||||
let elapsed = trace.last().unwrap().0 - trace.first().unwrap().0;
|
||||
assert!(
|
||||
(19_000..=19_100).contains(&elapsed),
|
||||
"replayed {elapsed} ms for 19 s of wall clock"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_recorded_trace_is_independent_of_connection_id() {
|
||||
let replay = |id: i32| {
|
||||
let text: String = (0..20)
|
||||
.map(|i| {
|
||||
format!(
|
||||
"qos_trace t={} id={id} delay=10 fps=30\n",
|
||||
100_000 + i * 1000
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"rustdesk-qos-replay-{}-{nonce}-{id}.log",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&path, text).unwrap();
|
||||
// Exercise the real replay entry point without changing other tests' environment.
|
||||
let test = format!(
|
||||
"{}::replay_recorded_trace",
|
||||
module_path!().split_once("::").unwrap().1
|
||||
);
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", &test, "--nocapture", "--test-threads=1"])
|
||||
.env("RUSTDESK_QOS_TRACE", &path)
|
||||
.output();
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let output = output.unwrap();
|
||||
assert!(output.status.success(), "replay failed: {output:?}");
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
let id = id.to_string();
|
||||
let fps: Vec<u32> = stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let fields: Vec<_> = line.split(',').collect();
|
||||
if fields.len() == 4 && fields[1] == id {
|
||||
Some(fields[3].parse().unwrap())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(fps.len(), 20, "missing replay samples: {stdout}");
|
||||
fps
|
||||
};
|
||||
let expected = replay(1);
|
||||
assert_eq!(expected.last(), Some(&30));
|
||||
assert_eq!(replay(1652), expected);
|
||||
}
|
||||
369
src/server/video_qos/tests/smoke.rs
Normal file
369
src/server/video_qos/tests/smoke.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn session(fps: u32, quality: Quality) -> VideoQoS {
|
||||
let mut qos = VideoQoS {
|
||||
fps: INIT_FPS.min(fps),
|
||||
abr_config: false,
|
||||
..Default::default()
|
||||
};
|
||||
qos.advance_ms(2000);
|
||||
qos.users.insert(
|
||||
1,
|
||||
UserData {
|
||||
custom_fps: Some(fps),
|
||||
quality: Some((0, quality)),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
qos
|
||||
}
|
||||
|
||||
fn profiles() -> Vec<(&'static str, Vec<u32>, bool)> {
|
||||
vec![
|
||||
("stable_10", vec![10; 120], false),
|
||||
("stable_80", vec![80; 120], false),
|
||||
("stable_180", vec![180; 120], false),
|
||||
("stable_300", vec![300; 120], false),
|
||||
(
|
||||
"lan_jitter",
|
||||
(0..120).map(|i| 10 + (i * 37 % 70)).collect(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"isolated_spikes",
|
||||
(0..120)
|
||||
.map(|i| if i % 15 == 0 { 800 } else { 10 })
|
||||
.collect(),
|
||||
true,
|
||||
),
|
||||
("alternating_10_350", [10, 350].repeat(60), true),
|
||||
(
|
||||
"two_sample_bursts",
|
||||
(0..120)
|
||||
.map(|i| if i % 12 < 2 { 700 } else { 10 })
|
||||
.collect(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"threshold_jitter",
|
||||
[140, 180, 150, 190, 130, 170].repeat(20),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"congestion_200_recovery",
|
||||
[vec![200; 20], vec![10; 60]].concat(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"congestion_800_recovery",
|
||||
[vec![800; 20], vec![10; 60]].concat(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"congestion_1500_recovery",
|
||||
[vec![1500; 20], vec![10; 60]].concat(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"rising_then_falling",
|
||||
(0..80).map(|i| 10 + i.min(79 - i) * 20).collect(),
|
||||
true,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_latency_profiles() {
|
||||
use std::fmt::Write;
|
||||
|
||||
let mut csv = String::from("profile,limit,quality,sample,delay_ms,fps\n");
|
||||
for (quality_name, quality) in [
|
||||
("balanced", Quality::Balanced),
|
||||
("best", Quality::Best),
|
||||
("low", Quality::Low),
|
||||
] {
|
||||
for limit in [1, 5, 15, 30, 60, 120] {
|
||||
for (name, delays, warm_up) in profiles() {
|
||||
let mut qos = session(limit, quality);
|
||||
if warm_up {
|
||||
for _ in 0..90 {
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
assert_eq!(qos.fps(), limit);
|
||||
}
|
||||
let mut trace = Vec::new();
|
||||
for (i, delay) in delays.into_iter().enumerate() {
|
||||
qos.user_network_delay(1, delay);
|
||||
let fps = qos.fps();
|
||||
assert!((MIN_FPS..=limit).contains(&fps), "{name}: {fps}");
|
||||
trace.push(fps);
|
||||
writeln!(csv, "{name},{limit},{quality_name},{i},{delay},{fps}").unwrap();
|
||||
}
|
||||
if limit == 30 && quality_name == "balanced" {
|
||||
println!(
|
||||
"{name}: first20={:?}, last={}",
|
||||
&trace[..20],
|
||||
trace.last().unwrap()
|
||||
);
|
||||
}
|
||||
if name.starts_with("stable_") {
|
||||
assert_eq!(trace.last(), Some(&limit), "{name}, {quality_name}");
|
||||
}
|
||||
if matches!(
|
||||
name,
|
||||
"lan_jitter"
|
||||
| "isolated_spikes"
|
||||
| "alternating_10_350"
|
||||
| "two_sample_bursts"
|
||||
| "threshold_jitter"
|
||||
) {
|
||||
assert!(
|
||||
trace.iter().all(|fps| *fps == limit),
|
||||
"{name}, {quality_name}"
|
||||
);
|
||||
}
|
||||
if matches!(name, "congestion_800_recovery" | "congestion_1500_recovery") {
|
||||
assert!(
|
||||
trace.iter().all(|fps| *fps >= limit.min(5)),
|
||||
"automatic reductions must preserve the floor: {trace:?}"
|
||||
);
|
||||
if limit >= 15 {
|
||||
assert!(
|
||||
trace[20] < limit,
|
||||
"a single good reply must not restore the full frame rate"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
trace[21], limit,
|
||||
"two fresh good replies must restore the frame rate"
|
||||
);
|
||||
if name == "congestion_1500_recovery" {
|
||||
assert_eq!(
|
||||
trace[5],
|
||||
limit.min(5),
|
||||
"severe congestion must brake promptly"
|
||||
);
|
||||
}
|
||||
}
|
||||
if name == "congestion_200_recovery" && limit >= 15 {
|
||||
assert!(
|
||||
trace[..20].iter().min() < Some(&limit),
|
||||
"moderate sustained congestion must reduce the frame rate: {trace:?}"
|
||||
);
|
||||
}
|
||||
if name.ends_with("_recovery") {
|
||||
assert_eq!(trace.last(), Some(&limit), "{name}, {quality_name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(path) = std::env::var("RUSTDESK_QOS_SMOKE_CSV") {
|
||||
std::fs::write(path, csv).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_bandwidth_drop_and_recovery() {
|
||||
use std::fmt::Write;
|
||||
|
||||
let mut qos = session(30, Quality::Balanced);
|
||||
for _ in 0..90 {
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
// Fixed-size frames, FIFO link and one outstanding TestDelay, with a 10 ms base RTT.
|
||||
// Encoding and ABR are intentionally absent so that FPS alone controls offered load.
|
||||
let mut queue = 0.0_f64;
|
||||
let mut probe: Option<(u32, f64, Option<u32>)> = None;
|
||||
let mut last_delay = 10;
|
||||
let mut csv = String::from("time_ms,capacity_fps,queue_ms,delay_ms,fps\n");
|
||||
let mut max_queue_ms = 0;
|
||||
let mut drained = false;
|
||||
let mut recovered_at = None;
|
||||
for now in (0..120_000).step_by(10) {
|
||||
qos.advance_ms(10);
|
||||
let capacity = if (10_000..70_000).contains(&now) {
|
||||
15.0
|
||||
} else {
|
||||
40.0
|
||||
};
|
||||
queue = (queue + (qos.fps() as f64 - capacity) * 0.01).max(0.0);
|
||||
if let Some((_, remaining, reply_at)) = probe.as_mut() {
|
||||
*remaining -= capacity * 0.01;
|
||||
if *remaining <= 0.0 && reply_at.is_none() {
|
||||
*reply_at = Some(now + 10);
|
||||
}
|
||||
}
|
||||
if let Some((sent, _, Some(reply_at))) = probe {
|
||||
if now >= reply_at {
|
||||
last_delay = now - sent;
|
||||
qos.user_network_delay(1, last_delay);
|
||||
probe = None;
|
||||
}
|
||||
}
|
||||
if now % 1000 == 0 {
|
||||
if probe.is_none() {
|
||||
probe = Some((now, queue, None));
|
||||
}
|
||||
qos.user_delay_response_elapsed(1, (now - probe.unwrap().0) as u128);
|
||||
let queue_ms = (queue / capacity * 1000.0) as u32;
|
||||
max_queue_ms = max_queue_ms.max(queue_ms);
|
||||
if (20_000..40_000).contains(&now) && queue_ms < 100 {
|
||||
drained = true;
|
||||
}
|
||||
if now >= 70_000 && qos.fps() == 30 && recovered_at.is_none() {
|
||||
recovered_at = Some(now - 70_000);
|
||||
}
|
||||
writeln!(
|
||||
csv,
|
||||
"{now},{capacity},{queue_ms},{last_delay},{}",
|
||||
qos.fps()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"bandwidth 40 -> 15 -> 40 fps: max_queue_ms={max_queue_ms}, recovery_ms={recovered_at:?}, final_fps={}",
|
||||
qos.fps()
|
||||
);
|
||||
if let Ok(path) = std::env::var("RUSTDESK_QOS_SMOKE_CSV") {
|
||||
std::fs::write(std::path::Path::new(&path).with_extension("queue.csv"), csv).unwrap();
|
||||
}
|
||||
assert!(drained, "congestion must drain after the capacity drop");
|
||||
assert!(
|
||||
max_queue_ms < 2500,
|
||||
"queue must not grow while awaiting confirmation"
|
||||
);
|
||||
assert!(
|
||||
recovered_at.is_some_and(|ms| ms <= 15_000) && qos.fps() == 30,
|
||||
"FPS must recover when capacity returns"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_capacity_with_short_stalls() {
|
||||
for limit in [30, 60] {
|
||||
// Probes run at one second cadence; sweep the stall phase so no alignment hides.
|
||||
for phase in (0..1000).step_by(50) {
|
||||
let mut qos = session(limit, Quality::Balanced);
|
||||
for _ in 0..90 {
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
let capacity = limit as f64 * 2.0;
|
||||
let mut queue = 0.0_f64;
|
||||
let mut probe: Option<(u32, f64, Option<u32>)> = None;
|
||||
let mut max_delay = 0;
|
||||
for now in (0..120_000).step_by(10) {
|
||||
qos.advance_ms(10);
|
||||
// A 700 ms pause affects video and probes on the same FIFO link.
|
||||
let available = if (now + phase) % 6000 < 700 {
|
||||
0.0
|
||||
} else {
|
||||
capacity
|
||||
};
|
||||
queue = (queue + (qos.fps() as f64 - available) * 0.01).max(0.0);
|
||||
if let Some((_, remaining, reply_at)) = probe.as_mut() {
|
||||
*remaining -= available * 0.01;
|
||||
if available > 0.0 && *remaining <= 0.0 && reply_at.is_none() {
|
||||
*reply_at = Some(now + 10);
|
||||
}
|
||||
}
|
||||
if let Some((sent, _, Some(reply_at))) = probe {
|
||||
if now >= reply_at {
|
||||
max_delay = max_delay.max(now - sent);
|
||||
qos.user_network_delay(1, now - sent);
|
||||
probe = None;
|
||||
}
|
||||
}
|
||||
if now % 1000 == 0 {
|
||||
if probe.is_none() {
|
||||
probe = Some((now, queue, None));
|
||||
}
|
||||
qos.user_delay_response_elapsed(1, (now - probe.unwrap().0) as u128);
|
||||
}
|
||||
assert_eq!(qos.fps(), limit, "limit={limit}, phase={phase}, time={now}");
|
||||
}
|
||||
println!("healthy FIFO link: limit={limit}, phase={phase}, max_delay_ms={max_delay}, final_fps={}", qos.fps());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_abr_bandwidth_drop_and_recovery() {
|
||||
for reduced_capacity in [27, 24, 15] {
|
||||
let mut qos = session(30, Quality::Balanced);
|
||||
for _ in 0..90 {
|
||||
qos.user_network_delay(1, 10);
|
||||
}
|
||||
qos.abr_config = true;
|
||||
qos.new_display("test".to_owned());
|
||||
qos.set_support_changing_quality("test", true);
|
||||
qos.store_bitrate(4000);
|
||||
let initial_ratio = qos.ratio();
|
||||
let mut queue = 0.0_f64;
|
||||
let mut probe: Option<(u32, f64, Option<u32>)> = None;
|
||||
let mut first_ratio_drop = None;
|
||||
let mut first_fps_drop = None;
|
||||
let mut first_fps_drop_delay = None;
|
||||
let mut last_delay = 10;
|
||||
let mut max_queue_ms = 0;
|
||||
let mut drained = false;
|
||||
let mut recovered_at = None;
|
||||
for now in (0..120_000).step_by(10) {
|
||||
qos.advance_ms(10);
|
||||
let capacity = if (10_000..70_000).contains(&now) {
|
||||
reduced_capacity as f64
|
||||
} else {
|
||||
40.0
|
||||
};
|
||||
// Frame size scales with the requested ratio; video and probes share one FIFO.
|
||||
let frame_size = (qos.ratio() / initial_ratio) as f64;
|
||||
queue = (queue + (qos.fps() as f64 * frame_size - capacity) * 0.01).max(0.0);
|
||||
qos.store_bitrate((4000.0 * frame_size) as u32);
|
||||
if let Some((_, remaining, reply_at)) = probe.as_mut() {
|
||||
*remaining -= capacity * 0.01;
|
||||
if *remaining <= 0.0 && reply_at.is_none() {
|
||||
*reply_at = Some(now + 10);
|
||||
}
|
||||
}
|
||||
if let Some((sent, _, Some(reply_at))) = probe {
|
||||
if now >= reply_at {
|
||||
last_delay = now - sent;
|
||||
qos.user_network_delay(1, last_delay);
|
||||
probe = None;
|
||||
}
|
||||
}
|
||||
if now % 1000 == 0 {
|
||||
if probe.is_none() {
|
||||
probe = Some((now, queue, None));
|
||||
}
|
||||
qos.user_delay_response_elapsed(1, (now - probe.unwrap().0) as u128);
|
||||
qos.update_display_data("test", qos.fps() as usize);
|
||||
let queue_ms = (queue / capacity * 1000.0) as u32;
|
||||
max_queue_ms = max_queue_ms.max(queue_ms);
|
||||
if (20_000..40_000).contains(&now) && queue_ms < 100 {
|
||||
drained = true;
|
||||
}
|
||||
}
|
||||
if qos.ratio() < initial_ratio && first_ratio_drop.is_none() {
|
||||
first_ratio_drop = Some(now);
|
||||
}
|
||||
if qos.fps() < 30 && first_fps_drop.is_none() {
|
||||
first_fps_drop = Some(now);
|
||||
first_fps_drop_delay = Some(last_delay);
|
||||
}
|
||||
if now >= 70_000 && qos.fps() == 30 && recovered_at.is_none() {
|
||||
recovered_at = Some(now - 70_000);
|
||||
}
|
||||
}
|
||||
println!("ABR bandwidth 40 -> {reduced_capacity} -> 40: max_queue_ms={max_queue_ms}, first_ratio_drop_ms={first_ratio_drop:?}, first_fps_drop_ms={first_fps_drop:?}, first_fps_drop_delay_ms={first_fps_drop_delay:?}, recovery_ms={recovered_at:?}, final_fps={}, final_ratio={:.3}", qos.fps(), qos.ratio());
|
||||
assert!(drained && max_queue_ms < 2500);
|
||||
assert!(recovered_at.is_some_and(|ms| ms <= 15_000));
|
||||
assert_eq!(qos.fps(), 30);
|
||||
assert!(qos.ratio() >= initial_ratio * 0.8);
|
||||
if reduced_capacity >= 24 {
|
||||
assert!(first_ratio_drop.is_some_and(|ratio_time| {
|
||||
first_fps_drop.map_or(true, |fps_time| ratio_time < fps_time)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
234
src/server/video_qos/tests/startup.rs
Normal file
234
src/server/video_qos/tests/startup.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
use super::*;
|
||||
|
||||
fn session(cap: u32, abr: bool) -> VideoQoS {
|
||||
let mut qos = super::smoke::session(cap, Quality::Balanced);
|
||||
let joined = qos.now();
|
||||
qos.users.get_mut(&1).unwrap().joined_at = Some(joined);
|
||||
qos.abr_config = abr;
|
||||
qos.new_display("startup".to_owned());
|
||||
qos.set_support_changing_quality("startup", true);
|
||||
qos
|
||||
}
|
||||
|
||||
fn reply(qos: &mut VideoQoS, delay: u32) {
|
||||
qos.user_network_delay(1, delay);
|
||||
let ratio = qos.ratio();
|
||||
qos.store_bitrate((6000.0 * ratio) as u32);
|
||||
qos.update_display_data("startup", qos.fps() as usize);
|
||||
qos.advance_ms(1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_startup_reaches_the_cap_without_waiting_for_slow_growth() {
|
||||
println!("| cap | base RTT ms | ABR | replies to cap | FPS after each reply |");
|
||||
println!("|---|---|---|---|---|");
|
||||
for (cap, budget) in [(3, 1), (15, 1), (30, 2), (60, 4), (120, 6)] {
|
||||
for base in [10, 150, 300, 600] {
|
||||
for abr in [false, true] {
|
||||
let mut qos = session(cap, abr);
|
||||
qos.advance_ms(base as u64);
|
||||
let mut trace = Vec::new();
|
||||
for n in 1..=budget {
|
||||
reply(&mut qos, base);
|
||||
let fps = qos.fps();
|
||||
assert!(fps <= cap);
|
||||
if n == 1 {
|
||||
assert!(fps <= INIT_FPS.min(cap), "keep the first-second guard");
|
||||
}
|
||||
trace.push(fps);
|
||||
}
|
||||
println!("| {cap} | {base} | {abr} | {budget} | {trace:?} |");
|
||||
assert_eq!(qos.fps(), cap, "base={base} ABR={abr}: {trace:?}");
|
||||
assert_eq!(qos.ratio(), Quality::Balanced.ratio());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclean_startup_reply_disables_faster_growth() {
|
||||
for excess in [50, 149, 150, 400] {
|
||||
let mut qos = session(120, false);
|
||||
reply(&mut qos, 10);
|
||||
reply(&mut qos, 10 + excess);
|
||||
for _ in 0..15 {
|
||||
let before = qos.fps();
|
||||
reply(&mut qos, 10);
|
||||
assert!(
|
||||
qos.fps() <= before + (before / 5).max(6),
|
||||
"excess={excess}: startup acceleration restarted: {before} -> {}",
|
||||
qos.fps()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_startup_probe_rolls_back_and_does_not_restart() {
|
||||
let mut qos = session(120, false);
|
||||
for _ in 0..2 {
|
||||
reply(&mut qos, 10);
|
||||
}
|
||||
let probe = qos.fps();
|
||||
assert!(probe >= 30, "fixture must reach the accelerated level");
|
||||
reply(&mut qos, 1200);
|
||||
assert!(qos.fps() <= probe / 2, "rollback must remain prompt");
|
||||
for _ in 0..2 {
|
||||
reply(&mut qos, 10);
|
||||
}
|
||||
assert_eq!(qos.fps(), probe, "keep the existing two-reply recovery");
|
||||
for _ in 0..8 {
|
||||
let before = qos.fps();
|
||||
reply(&mut qos, 10);
|
||||
assert!(qos.fps() <= before + (before / 5).max(6));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_startup_spike_still_requires_congestion_confirmation() {
|
||||
let mut qos = session(120, false);
|
||||
for _ in 0..2 {
|
||||
reply(&mut qos, 10);
|
||||
}
|
||||
let probe = qos.fps();
|
||||
for _ in 0..2 {
|
||||
reply(&mut qos, 800);
|
||||
assert_eq!(qos.fps(), probe, "startup is not a failed fast restore");
|
||||
}
|
||||
reply(&mut qos, 800);
|
||||
assert!(qos.fps() < probe, "three fresh bad replies must reduce FPS");
|
||||
assert!(qos.fps() >= probe - probe / 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_startup_timeout_keeps_legacy_recovery() {
|
||||
let mut qos = session(120, false);
|
||||
qos.advance_ms(3000);
|
||||
qos.user_delay_response_elapsed(1, 3001);
|
||||
assert_eq!(qos.fps(), 5);
|
||||
reply(&mut qos, 3100);
|
||||
assert_eq!(qos.fps(), 5, "late reply must not undo the brake");
|
||||
for _ in 0..2 {
|
||||
reply(&mut qos, 10);
|
||||
}
|
||||
assert_eq!(qos.fps(), INIT_FPS);
|
||||
for _ in 0..8 {
|
||||
let before = qos.fps();
|
||||
reply(&mut qos, 10);
|
||||
assert!(qos.fps() <= before + (before / 5).max(6));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raising_the_cap_does_not_restart_startup_acceleration() {
|
||||
let mut qos = session(30, false);
|
||||
for _ in 0..6 {
|
||||
reply(&mut qos, 10);
|
||||
}
|
||||
assert_eq!(qos.fps(), 30);
|
||||
qos.user_custom_fps(1, 120);
|
||||
for _ in 0..6 {
|
||||
let before = qos.fps();
|
||||
reply(&mut qos, 10);
|
||||
assert!(qos.fps() <= before + (before / 5).max(6));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_remains_per_viewer_and_preserves_the_join_guard() {
|
||||
let mut qos = session(120, false);
|
||||
for _ in 0..20 {
|
||||
reply(&mut qos, 10);
|
||||
}
|
||||
qos.users.insert(
|
||||
2,
|
||||
UserData {
|
||||
joined_at: Some(qos.now()),
|
||||
custom_fps: Some(30),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
qos.user_network_delay(2, 10);
|
||||
assert_eq!(qos.fps(), INIT_FPS);
|
||||
assert_eq!(qos.users[&1].delay.fps, Some(120));
|
||||
qos.advance_ms(1000);
|
||||
qos.user_network_delay(2, 10);
|
||||
assert_eq!(qos.users[&2].delay.fps, Some(30));
|
||||
assert_eq!(qos.fps(), 30);
|
||||
qos.on_connection_close(2);
|
||||
assert_eq!(qos.fps(), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_loop_startup_preserves_quality_and_bounds_queueing() {
|
||||
use super::sim::{self, EncoderModel};
|
||||
for cap in [30, 60, 120] {
|
||||
for base in [10, 150, 300, 600] {
|
||||
for encoder in [EncoderModel::Cbr, EncoderModel::FixedRate] {
|
||||
for seed in 1..=5 {
|
||||
let mut sc = sim::scenarios()
|
||||
.into_iter()
|
||||
.find(|sc| sc.name == "city_relay_30")
|
||||
.unwrap();
|
||||
sc.seconds = 30;
|
||||
sc.limit = cap;
|
||||
sc.link.base_rtt_ms = base as f64;
|
||||
sc.encoder = encoder;
|
||||
sc.seed = seed;
|
||||
let report = sim::run(&sc);
|
||||
let budget = match cap {
|
||||
30 => 2000,
|
||||
60 => 4000,
|
||||
_ => 6000,
|
||||
} + base;
|
||||
assert!(
|
||||
report.time_to_90pct_ms.is_some_and(|t| t <= budget),
|
||||
"cap={cap} base={base}: {report:?}"
|
||||
);
|
||||
assert_eq!(report.final_fps, cap);
|
||||
assert_eq!(report.final_ratio, sc.quality.ratio());
|
||||
assert!(report.trace.iter().all(|(_, _, queue, _)| *queue < 150));
|
||||
assert!(report.frame_age_p95_ms < 150);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_startup_does_not_leave_a_large_queue() {
|
||||
use super::sim::{self, EncoderModel};
|
||||
for cap in [30, 60, 120] {
|
||||
for encoder in [EncoderModel::Cbr, EncoderModel::FixedRate] {
|
||||
for seed in sim::SEEDS {
|
||||
let mut sc = sim::scenarios()
|
||||
.into_iter()
|
||||
.find(|sc| sc.name == "bandwidth_halved_30")
|
||||
.unwrap();
|
||||
sc.limit = cap;
|
||||
sc.encoder = encoder;
|
||||
sc.seed = seed;
|
||||
sc.link.capacity_kbps = vec![(0, 2500.0)];
|
||||
let report = sim::run(&sc);
|
||||
let startup_queue = report
|
||||
.trace
|
||||
.iter()
|
||||
.filter(|(t, ..)| *t < 15_000)
|
||||
.map(|(_, _, queue, _)| *queue)
|
||||
.max()
|
||||
.unwrap();
|
||||
println!("startup limited cap={cap} seed={seed} frame_budget={} peak_queue_ms={startup_queue} queue_p95_ms={} age_p95_ms={}", encoder == EncoderModel::FixedRate, report.queue_p95_ms, report.frame_age_p95_ms);
|
||||
assert!(
|
||||
startup_queue < 4000,
|
||||
"cap={cap} seed={seed}: {startup_queue}"
|
||||
);
|
||||
assert!(report.queue_p95_ms < 3000);
|
||||
assert!(report.frame_age_p95_ms < 3000);
|
||||
assert!(report
|
||||
.trace
|
||||
.iter()
|
||||
.all(|(_, fps, _, _)| (5..=cap).contains(fps)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,6 +655,12 @@ 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)]
|
||||
@@ -665,6 +671,8 @@ fn run(vs: VideoService) -> ResultType<()> {
|
||||
&mut spf,
|
||||
client_record,
|
||||
&mut send_counter,
|
||||
&mut sent_counter,
|
||||
&mut wait_max_ms,
|
||||
&mut second_instant,
|
||||
&sp.name(),
|
||||
)?;
|
||||
@@ -785,6 +793,9 @@ 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;
|
||||
}
|
||||
@@ -844,6 +855,9 @@ 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;
|
||||
}
|
||||
@@ -885,6 +899,7 @@ 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();
|
||||
@@ -1315,12 +1330,22 @@ 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<()> {
|
||||
@@ -1346,7 +1371,21 @@ 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(())
|
||||
|
||||
@@ -21,7 +21,7 @@ lazy_static::lazy_static! {
|
||||
static ref CAP_DISPLAY_INFO: RwLock<HashMap<usize, u64>> = RwLock::new(HashMap::new());
|
||||
static ref PIPEWIRE_INITIALIZED: RwLock<bool> = RwLock::new(false);
|
||||
static ref LOG_SCRAP_COUNT: Mutex<u32> = Mutex::new(0);
|
||||
static ref LAST_STAGE_ERR: Mutex<String> = Mutex::new(String::new());
|
||||
static ref LAST_STAGE_ERR: Mutex<Option<(String, std::time::Instant)>> = Mutex::new(None);
|
||||
static ref ACTIVE_DISPLAY_COUNT: RwLock<usize> = RwLock::new(0);
|
||||
}
|
||||
|
||||
@@ -101,11 +101,21 @@ fn map_staged_err(err: anyhow::Error) -> anyhow::Error {
|
||||
}
|
||||
}
|
||||
|
||||
// The video service retries about once a second, so a wedged portal would otherwise write a
|
||||
// line a second forever. Repeat the message only when the cause changes, or after long
|
||||
// enough that a reader would want to see the fault is still there.
|
||||
const STAGE_ERR_REPEAT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
fn log_staged_once(err: &str) {
|
||||
let now = std::time::Instant::now();
|
||||
let mut last = LAST_STAGE_ERR.lock().unwrap();
|
||||
if *last != err {
|
||||
let repeat = match last.as_ref() {
|
||||
Some((seen, at)) => seen != err || now.duration_since(*at) >= STAGE_ERR_REPEAT,
|
||||
None => true,
|
||||
};
|
||||
if repeat {
|
||||
log::error!("Wayland portal handshake failed: {}", err);
|
||||
*last = err.to_owned();
|
||||
*last = Some((err.to_owned(), now));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,14 +133,17 @@ fn try_log(err: &String) {
|
||||
// Translation keys, so the key itself is the English text: an older peer that has never heard
|
||||
// of them falls back to displaying the key and still reads as a sentence.
|
||||
const WAYLAND_DECLINED: &str = "The screen sharing request was declined on the remote device";
|
||||
const WAYLAND_NO_ANSWER: &str =
|
||||
"No one responded to the screen sharing request on the remote device";
|
||||
const WAYLAND_PORTAL_ENDED: &str = "The XDG Desktop Portal ended the screen sharing request ({})";
|
||||
const WAYLAND_TIMED_OUT: &str = "The screen sharing request timed out on the remote device";
|
||||
const WAYLAND_NO_SESSION: &str = "RustDesk cannot reach the desktop session on the remote device, check that a desktop session is running and that RustDesk can use it";
|
||||
const WAYLAND_UNSUPPORTED: &str = "The desktop portal on the remote device is missing a capability needed for screen sharing or remote control, its backend may not be installed";
|
||||
const WAYLAND_PIPEWIRE_HANDOVER: &str = "Screen sharing was approved on the remote device, but the PipeWire connection could not be opened";
|
||||
const WAYLAND_ENDED: &str =
|
||||
"The screen sharing request ended without completing on the remote device";
|
||||
// The remedy the message it replaces used to carry, minus the link: this is the outcome
|
||||
// rustdesk/rustdesk#8600 is about.
|
||||
const WAYLAND_NO_SCREEN: &str =
|
||||
"The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old";
|
||||
const WAYLAND_GST_MISSING: &str = "A GStreamer plugin needed for screen capture is missing ({})";
|
||||
const WAYLAND_NO_USABLE_SCREEN: &str = "RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old";
|
||||
const WAYLAND_GST_UNAVAILABLE: &str =
|
||||
"RustDesk could not load a GStreamer component needed for screen capture ({})";
|
||||
|
||||
const WAYLAND_STAGE_TAG: &str = "wl-stage:";
|
||||
|
||||
@@ -166,10 +179,20 @@ fn staged_message(tag: &str, ubuntu_before_21: bool) -> String {
|
||||
|
||||
match (stage, kind) {
|
||||
(_, "declined") => WAYLAND_DECLINED.to_owned(),
|
||||
(_, "portal-error") => with_detail(WAYLAND_PORTAL_ENDED, detail),
|
||||
("start", "no-response") => WAYLAND_NO_ANSWER.to_owned(),
|
||||
("streams", _) => of_the_machine(WAYLAND_NO_SCREEN),
|
||||
("gst-plugin", _) => of_the_machine(&with_detail(WAYLAND_GST_MISSING, detail)),
|
||||
(_, "ended") => WAYLAND_ENDED.to_owned(),
|
||||
(_, "no-response") => WAYLAND_TIMED_OUT.to_owned(),
|
||||
("streams", _) => of_the_machine(WAYLAND_NO_USABLE_SCREEN),
|
||||
("gst-plugin", _) => of_the_machine(&with_detail(WAYLAND_GST_UNAVAILABLE, detail)),
|
||||
// The bus the portal lives on was never reached, so the portal has not been asked
|
||||
// anything yet and telling anyone to restart it would be a guess.
|
||||
("session-bus", _) => of_the_machine(WAYLAND_NO_SESSION),
|
||||
// The portal answered `Start`, so the request was granted and the only thing left
|
||||
// was handing over the PipeWire connection. Whatever went wrong, it is not the
|
||||
// portal being unavailable -- it had just answered.
|
||||
("open-pipewire-remote", _) => of_the_machine(WAYLAND_PIPEWIRE_HANDOVER),
|
||||
// The portal is there and answering; it just does not implement what was called,
|
||||
// which restarting it cannot fix.
|
||||
(_, "unsupported") => of_the_machine(WAYLAND_UNSUPPORTED),
|
||||
// Everything else is the portal not delivering, which is what this key already says --
|
||||
// and unlike a message of our own it carries the `systemctl --user restart` remedy.
|
||||
_ => of_the_machine(SCRAP_XDP_PORTAL_UNAVAILABLE),
|
||||
@@ -181,27 +204,45 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn staged_message_names_the_stage() {
|
||||
fn staged_message_names_the_outcome() {
|
||||
let m = |tag| staged_message(tag, false);
|
||||
assert_eq!(m("start:declined:"), WAYLAND_DECLINED);
|
||||
assert_eq!(m("start:no-response:"), WAYLAND_NO_ANSWER);
|
||||
assert_eq!(m("streams:empty:"), WAYLAND_NO_SCREEN);
|
||||
assert_eq!(m("start:ended:"), WAYLAND_ENDED);
|
||||
assert_eq!(m("start:no-response:"), WAYLAND_TIMED_OUT);
|
||||
// A restored session shows no picker at all, so a timeout anywhere is a timeout and
|
||||
// never a claim about someone not answering.
|
||||
assert_eq!(m("create-session:no-response:"), WAYLAND_TIMED_OUT);
|
||||
assert_eq!(m("streams:empty:"), WAYLAND_NO_USABLE_SCREEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_portal_that_did_not_deliver_keeps_the_message_that_says_how_to_restart_it() {
|
||||
fn only_a_portal_that_may_be_dead_is_told_to_restart() {
|
||||
let m = |tag| staged_message(tag, false);
|
||||
// Not reached the bus at all: the portal has not been asked anything yet.
|
||||
assert_eq!(
|
||||
m("session-bus:dbus:org.freedesktop.DBus.Error.NotSupported"),
|
||||
WAYLAND_NO_SESSION
|
||||
);
|
||||
// Answering, but without an implementation behind the interface that was called --
|
||||
// at any stage, not just the first one.
|
||||
assert_eq!(
|
||||
m("create-session:unsupported:org.freedesktop.DBus.Error.UnknownMethod"),
|
||||
WAYLAND_UNSUPPORTED
|
||||
);
|
||||
assert_eq!(
|
||||
m("select-sources:unsupported:org.freedesktop.DBus.Error.UnknownMethod"),
|
||||
WAYLAND_UNSUPPORTED
|
||||
);
|
||||
// Absent or silent, which is what the existing key's remedy is for.
|
||||
assert_eq!(
|
||||
m("create-session:dbus:org.freedesktop.DBus.Error.ServiceUnknown"),
|
||||
SCRAP_XDP_PORTAL_UNAVAILABLE
|
||||
);
|
||||
// Not this one: `Start` had already been answered, so the portal was alive and the
|
||||
// request granted. Saying it may have crashed would walk the diagnosis backwards.
|
||||
assert_eq!(
|
||||
m("create-session:no-response:"),
|
||||
SCRAP_XDP_PORTAL_UNAVAILABLE
|
||||
);
|
||||
assert_eq!(
|
||||
m("select-sources:internal:no session_handle"),
|
||||
SCRAP_XDP_PORTAL_UNAVAILABLE
|
||||
m("open-pipewire-remote:dbus:org.freedesktop.DBus.Error.Failed"),
|
||||
WAYLAND_PIPEWIRE_HANDOVER
|
||||
);
|
||||
// A tag this build does not know must never fall back to a guess.
|
||||
assert_eq!(
|
||||
@@ -212,27 +253,20 @@ mod tests {
|
||||
}
|
||||
|
||||
// The peer resolves a message by replacing its first `{...}` with `{}` and looking that
|
||||
// up, so every detail-carrying message has to reduce back to its key exactly.
|
||||
// up, so a detail-carrying message has to reduce back to its key exactly.
|
||||
#[test]
|
||||
fn a_detail_carrying_message_reduces_back_to_its_key() {
|
||||
let reduce = |s: &str| {
|
||||
let open = s.find('{').expect("no placeholder");
|
||||
let close = s[open..].find('}').expect("unclosed placeholder") + open;
|
||||
format!("{}{{}}{}", &s[..open], &s[close + 1..])
|
||||
};
|
||||
let ended = staged_message("start:portal-error:2", false);
|
||||
assert_eq!(
|
||||
ended,
|
||||
"The XDG Desktop Portal ended the screen sharing request ({2})"
|
||||
);
|
||||
assert_eq!(reduce(&ended), WAYLAND_PORTAL_ENDED);
|
||||
|
||||
let gst = staged_message("gst-plugin:missing:pipewiresrc", false);
|
||||
let gst = staged_message("gst-plugin:unavailable:pipewiresrc", false);
|
||||
assert_eq!(
|
||||
gst,
|
||||
"A GStreamer plugin needed for screen capture is missing ({pipewiresrc})"
|
||||
"RustDesk could not load a GStreamer component needed for screen capture ({pipewiresrc})"
|
||||
);
|
||||
let open = gst.find('{').expect("no placeholder");
|
||||
let close = gst[open..].find('}').expect("unclosed placeholder") + open;
|
||||
assert_eq!(
|
||||
format!("{}{{}}{}", &gst[..open], &gst[close + 1..]),
|
||||
WAYLAND_GST_UNAVAILABLE
|
||||
);
|
||||
assert_eq!(reduce(&gst), WAYLAND_GST_MISSING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -243,12 +277,18 @@ mod tests {
|
||||
SCRAP_UBUNTU_HIGHER_REQUIRED
|
||||
);
|
||||
assert_eq!(
|
||||
m("gst-plugin:missing:pipewiresrc"),
|
||||
m("create-session:unsupported:org.freedesktop.DBus.Error.UnknownMethod"),
|
||||
SCRAP_UBUNTU_HIGHER_REQUIRED
|
||||
);
|
||||
assert_eq!(
|
||||
m("gst-plugin:unavailable:pipewiresrc"),
|
||||
SCRAP_UBUNTU_HIGHER_REQUIRED
|
||||
);
|
||||
assert_eq!(m("streams:empty:"), SCRAP_UBUNTU_HIGHER_REQUIRED);
|
||||
assert_eq!(m("session-bus:dbus:"), SCRAP_UBUNTU_HIGHER_REQUIRED);
|
||||
assert_eq!(m("start:declined:"), WAYLAND_DECLINED);
|
||||
assert_eq!(m("start:no-response:"), WAYLAND_NO_ANSWER);
|
||||
assert_eq!(m("start:ended:"), WAYLAND_ENDED);
|
||||
assert_eq!(m("start:no-response:"), WAYLAND_TIMED_OUT);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user